diff --git a/apps/csm-portal/webapp/src/api/backend/client.ts b/apps/csm-portal/webapp/src/api/backend/client.ts index c163460221..b304fd39c4 100644 --- a/apps/csm-portal/webapp/src/api/backend/client.ts +++ b/apps/csm-portal/webapp/src/api/backend/client.ts @@ -80,9 +80,22 @@ async function readError( * fetch and JSON (de)serialization. 404s on GET resolve to `null` so the * common "not found" case can be handled without exceptions. */ +/** Per-call options accepted by {@link BackendApi.post} on top of its + * `path`/`body` — currently just an optional `AbortSignal`, forwarded + * straight through to the underlying `fetch`. Its own object type (rather + * than a bare `AbortSignal` parameter) so a future addition here doesn't + * need every existing call site to learn a new positional argument. */ +export interface BackendApiPostOptions { + signal?: AbortSignal; +} + export interface BackendApi { get(path: string): Promise; - post(path: string, body: TRequest): Promise; + post( + path: string, + body: TRequest, + options?: BackendApiPostOptions, + ): Promise; patch(path: string, body: TRequest): Promise; postEmpty(path: string): Promise; /** Authenticated DELETE. Returns the parsed JSON body (`null` on 204). */ @@ -126,6 +139,7 @@ export function useBackendApi(): BackendApi { async post( path: string, body: TRequest, + options?: BackendApiPostOptions, ): Promise { const correlationId = newCorrelationId(); const response = await authFetch(buildUrl(path), { @@ -135,6 +149,7 @@ export function useBackendApi(): BackendApi { [CORRELATION_ID_HEADER]: correlationId, }, body: JSON.stringify(body), + signal: options?.signal, }); if (!response.ok) throw await readError(response, correlationId); return (await response.json()) as TResponse; diff --git a/apps/csm-portal/webapp/src/features/csm-admin/dashboards/components/WidgetEditorDialog.test.tsx b/apps/csm-portal/webapp/src/features/csm-admin/dashboards/components/WidgetEditorDialog.test.tsx index 5378ae5ae8..125f5af22f 100644 --- a/apps/csm-portal/webapp/src/features/csm-admin/dashboards/components/WidgetEditorDialog.test.tsx +++ b/apps/csm-portal/webapp/src/features/csm-admin/dashboards/components/WidgetEditorDialog.test.tsx @@ -100,10 +100,14 @@ describe("WidgetEditorDialog", () => { fireEvent.click(screen.getByRole("button", { name: /^preview$/i })); await waitFor(() => expect(screen.getByText("7")).toBeInTheDocument()); - expect(postMock).toHaveBeenCalledWith("/cases/search", { - filters: {}, - pagination: { offset: 0, limit: 1 }, - }); + expect(postMock).toHaveBeenCalledWith( + "/cases/search", + { + filters: {}, + pagination: { offset: 0, limit: 1 }, + }, + { signal: expect.any(AbortSignal) }, + ); }); it("shows nothing fetched until Preview is explicitly clicked", () => { @@ -172,10 +176,14 @@ describe("WidgetEditorDialog", () => { fireEvent.click(screen.getByRole("button", { name: /^preview$/i })); await waitFor(() => - expect(postMock).toHaveBeenCalledWith("/cases/search", { - filters: { filters: [{ field: "integrationCsTeam", op: "in", values: ["team-group-1"] }] }, - pagination: { offset: 0, limit: 1 }, - }), + expect(postMock).toHaveBeenCalledWith( + "/cases/search", + { + filters: { filters: [{ field: "integrationCsTeam", op: "in", values: ["team-group-1"] }] }, + pagination: { offset: 0, limit: 1 }, + }, + { signal: expect.any(AbortSignal) }, + ), ); }); diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetData.test.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetData.test.tsx new file mode 100644 index 0000000000..ab5029fd96 --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetData.test.tsx @@ -0,0 +1,323 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { render, renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import type { ReactNode } from "react"; + +const postMock = vi.fn(); + +vi.mock("@api/backend/client", () => ({ + useBackendApi: () => ({ post: postMock }), +})); + +import { useWidgetData } from "@features/csm-dashboard/api/useWidgetData"; +import { + WIDGET_FETCH_CONCURRENCY_LIMIT, + __resetWidgetFetchConcurrencyForTests, + __setWidgetFetchTimeoutMsForTests, +} from "@features/csm-dashboard/utils/widgetFetchConcurrency"; + +function wrapper({ children }: { children: ReactNode }) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return {children}; +} + +/** One dashboard tile: mounts its own independent useWidgetData query, same + * as DashboardWidgetTile does per widget — the real fan-out this task caps. */ +function Widget({ id }: { id: string }) { + useWidgetData(id, "case", { states: ["open"] }, "count"); + return null; +} + +function Dashboard({ widgetIds }: { widgetIds: string[] }) { + return ( + + {widgetIds.map((id) => ( + + ))} + + ); +} + +describe("useWidgetData", () => { + beforeEach(() => { + postMock.mockReset(); + __resetWidgetFetchConcurrencyForTests(); + }); + + it("issues one search for the widget's own filters, shape count uses limit 1", async () => { + postMock.mockResolvedValue({ total: 7, items: [] }); + + renderHook(() => useWidgetData("w1", "case", { states: ["open"] }, "count"), { wrapper }); + + await waitFor(() => expect(postMock).toHaveBeenCalled()); + expect(postMock).toHaveBeenCalledWith( + "/cases/search", + { + filters: { states: ["open"] }, + pagination: { offset: 0, limit: 1 }, + }, + { signal: expect.any(AbortSignal) }, + ); + }); + + it("caps concurrent in-flight /cases/search calls at WIDGET_FETCH_CONCURRENCY_LIMIT when a dashboard's worth of widgets all mount at once", async () => { + // A dashboard with more widgets than abt-engineer's ~20 — deliberately + // not a clean multiple of the cap. + const widgetCount = WIDGET_FETCH_CONCURRENCY_LIMIT * 3 + 4; + + let inFlight = 0; + let peakInFlight = 0; + const releasers: Array<() => void> = []; + + postMock.mockImplementation( + () => + new Promise((resolve) => { + inFlight += 1; + peakInFlight = Math.max(peakInFlight, inFlight); + releasers.push(() => { + inFlight -= 1; + resolve({ total: 1, items: [] }); + }); + }), + ); + + const widgetIds = Array.from({ length: widgetCount }, (_, i) => `w${i}`); + render(); + + // Let every widget's query mount and request a slot. + await waitFor(() => expect(postMock.mock.calls.length).toBeGreaterThan(0)); + + // Every widget beyond the cap must still be queued, not fired — the + // core assertion this task exists to prove. + expect(peakInFlight).toBeLessThanOrEqual(WIDGET_FETCH_CONCURRENCY_LIMIT); + expect(postMock.mock.calls.length).toBeLessThanOrEqual(WIDGET_FETCH_CONCURRENCY_LIMIT); + + // Drain the queue in batches, exactly as a real backend finishing + // requests over time would — the queued widgets fire in turn and the + // cap continues to hold, until every widget has fetched. + while (postMock.mock.calls.length < widgetCount || releasers.length > 0) { + const batch = releasers.splice(0, releasers.length); + batch.forEach((release) => release()); + await waitFor(() => expect(inFlight).toBe(0)); + expect(peakInFlight).toBeLessThanOrEqual(WIDGET_FETCH_CONCURRENCY_LIMIT); + if (postMock.mock.calls.length >= widgetCount) break; + await waitFor(() => expect(postMock.mock.calls.length).toBeGreaterThan(0)); + } + + await waitFor(() => expect(postMock.mock.calls.length).toBe(widgetCount)); + expect(peakInFlight).toBeLessThanOrEqual(WIDGET_FETCH_CONCURRENCY_LIMIT); + }); + + describe("client-side request timeout", () => { + // Real timers throughout this block, deliberately — see + // __setWidgetFetchTimeoutMsForTests's own doc comment: this app's + // actual react-query + React versions did not observably propagate a + // settled query's state to a hook's result under + // vi.useFakeTimers()/advanceTimersByTimeAsync in direct testing (a bare + // two-hook useQuery repro, no custom code at all, stayed "pending" + // forever), so real timers + a shrunk-down real timeout is the + // reliable path here, not a stylistic choice. + const TEST_TIMEOUT_MS = 60; + // For "must NOT have happened yet" assertions: a plain real sleep + // shorter than TEST_TIMEOUT_MS, not `waitFor`'s own default ~50ms poll + // interval — that interval is close enough to a very small + // TEST_TIMEOUT_MS that a `waitFor` resolving on its first poll could + // already be past the timeout by the time this test's next assertion + // runs, producing a flaky false negative unrelated to the actual + // behaviour being proven. + const SHORT_SETTLE_MS = 15; + + function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + // react-query's own default retryDelay (~1000ms for the first retry) + // would make every test in this block take a real ~1s+ for no benefit + // to what's being proven — this wrapper's QueryClient shortens it to a + // few ms. Local to this describe block; every other test in this file + // keeps using the shared, unmodified `wrapper`. + function wrapperWithFastRetry({ children }: { children: ReactNode }) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, retryDelay: 50 } }, + }); + return {children}; + } + + beforeEach(() => { + __setWidgetFetchTimeoutMsForTests(TEST_TIMEOUT_MS); + }); + + afterEach(() => { + __setWidgetFetchTimeoutMsForTests(10_000); + }); + + /** A hung `postMock` implementation: never settles on its own, only + * rejects (with the same `AbortError` shape a real aborted `fetch` + * would produce) once its own `signal` fires. Pushes `label` onto + * `events` the instant it's invoked, so tests can assert call ORDER, + * not just call count. */ + function hungCall(events: string[], label: string) { + return (_path: string, _body: unknown, options?: { signal?: AbortSignal }) => { + events.push(label); + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener("abort", () => { + const abortError = new Error("The operation was aborted."); + abortError.name = "AbortError"; + reject(abortError); + }); + }); + }; + } + + it("aborts a widget fetch that never resolves once the configured timeout elapses, and releases the slot so the next queued widget's fetch fires immediately (not after the retry)", async () => { + const events: string[] = []; + let callCount = 0; + postMock.mockImplementation((path, body, options?: { signal?: AbortSignal }) => { + callCount += 1; + if (callCount === 1) return hungCall(events, "w-hung:attempt-1")(path, body, options); + events.push("w-queued:call"); + return Promise.resolve({ total: 5, items: [] }); + }); + + const first = renderHook( + () => useWidgetData("w-hung", "case", { states: ["open"] }, "count"), + { wrapper: wrapperWithFastRetry }, + ); + const second = renderHook( + () => useWidgetData("w-queued", "case", { states: ["open"] }, "count"), + { wrapper: wrapperWithFastRetry }, + ); + + // Widget 1 fires immediately (it acquires the — sole, since + // WIDGET_FETCH_CONCURRENCY_LIMIT is 1 — slot); widget 2 is queued + // behind it and must still be waiting well before the timeout. + await sleep(SHORT_SETTLE_MS); + expect(events).toEqual(["w-hung:attempt-1"]); + expect(first.result.current.isLoading).toBe(true); + + // Crossing the deadline aborts widget 1's first attempt, which + // releases its slot immediately — widget 2 must fire right away, not + // wait for widget 1's retry. + await waitFor(() => expect(events).toContain("w-queued:call")); + expect(events).toEqual(["w-hung:attempt-1", "w-queued:call"]); + await waitFor(() => + expect(second.result.current.data).toEqual({ total: 5, items: [] }), + ); + // Widget 1 itself is NOT yet in a terminal error state — a timeout is + // retried once (see the next test), so it's still "loading" (now + // waiting out react-query's retry backoff), not failed. + expect(first.result.current.isError).toBe(false); + expect(first.result.current.isLoading).toBe(true); + }); + + it("retries a timed-out widget exactly once, after the rest of the queue has already had its turn, and shows the resolved data on success", async () => { + const events: string[] = []; + // One shared mock, keyed purely on call order — the module-level + // semaphore doesn't distinguish which widget owns a given attempt, + // so neither does this test's setup: whichever widget's queryFn + // actually calls postMock next determines what happens. + let callCount = 0; + postMock.mockImplementation((path, body, options?: { signal?: AbortSignal }) => { + callCount += 1; + if (callCount === 1) { + // w-retry's 1st attempt: hangs, only rejects on abort. + return hungCall(events, "w-retry:attempt-1")(path, body, options); + } + if (callCount === 2) { + // w-other: resolves immediately — the "rest of the queue" the + // retry must land behind. + events.push("w-other:call"); + return Promise.resolve({ total: 2, items: [] }); + } + // w-retry's 2nd attempt (the retry): succeeds. + events.push("w-retry:attempt-2"); + return Promise.resolve({ total: 9, items: [] }); + }); + + const retrying = renderHook( + () => useWidgetData("w-retry", "case", { states: ["open"] }, "count"), + { wrapper: wrapperWithFastRetry }, + ); + const other = renderHook( + () => useWidgetData("w-other", "case", { severities: ["critical"] }, "count"), + { wrapper: wrapperWithFastRetry }, + ); + + // Attempt 1 fires immediately; the other widget queues behind it and + // must still be waiting well before the timeout. + await sleep(SHORT_SETTLE_MS); + expect(events).toEqual(["w-retry:attempt-1"]); + + // Attempt 1 times out — its slot releases immediately, so the queued + // widget fires right away, well before the retry (which is on its + // own ~50ms backoff, started only once attempt 1 actually failed). + await waitFor(() => expect(events).toContain("w-other:call")); + expect(events).toEqual(["w-retry:attempt-1", "w-other:call"]); + await waitFor(() => + expect(other.result.current.data).toEqual({ total: 2, items: [] }), + ); + // The retry must not have appeared yet, this soon after the other + // widget's own data first became available — this is the ordering + // assertion that actually matters, not just eventual call counts. + expect(events).toEqual(["w-retry:attempt-1", "w-other:call"]); + + // The retried widget's 2nd attempt fires afterward and succeeds. + await waitFor(() => expect(events).toContain("w-retry:attempt-2")); + expect(events).toEqual(["w-retry:attempt-1", "w-other:call", "w-retry:attempt-2"]); + await waitFor(() => + expect(retrying.result.current.data).toEqual({ total: 9, items: [] }), + ); + expect(retrying.result.current.isError).toBe(false); + }); + + it("does not retry forever: a widget whose retry ALSO times out reaches a real terminal error state, with no 3rd attempt", async () => { + const events: string[] = []; + let callCount = 0; + postMock.mockImplementation((path, body, options?: { signal?: AbortSignal }) => { + callCount += 1; + return hungCall(events, `attempt-${callCount}`)(path, body, options); + }); + + const { result } = renderHook( + () => useWidgetData("w-double-timeout", "case", {}, "count"), + { wrapper: wrapperWithFastRetry }, + ); + + // Attempt 1 times out. + await waitFor(() => expect(events).toContain("attempt-1")); + // Not terminal yet — one retry is still owed. + expect(result.current.isError).toBe(false); + + // Attempt 2 (the retry) also times out. + await waitFor(() => expect(events).toContain("attempt-2")); + expect(events).toEqual(["attempt-1", "attempt-2"]); + + // NOW it's a real terminal failure. + await waitFor(() => expect(result.current.isError).toBe(true)); + + // Give a hypothetical 3rd attempt every chance to have fired — it + // must not: the one-retry budget is exhausted. + await new Promise((resolve) => setTimeout(resolve, TEST_TIMEOUT_MS * 3)); + expect(events).toEqual(["attempt-1", "attempt-2"]); + expect(result.current.isError).toBe(true); + }); + }); +}); diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetData.ts b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetData.ts index e98dabbf38..e4a62b1fae 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetData.ts +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetData.ts @@ -25,6 +25,10 @@ import { hasCurrentUserPlaceholder, resolveCurrentUserPlaceholder, } from "@features/csm-dashboard/utils/currentUserFilterPlaceholder"; +import { + shouldRetryWidgetFetch, + withWidgetFetchSlot, +} from "@features/csm-dashboard/utils/widgetFetchConcurrency"; /** Default number of rows fetched for a `shape: "list"` widget when the * template doesn't set its own `listLimit`. */ @@ -120,25 +124,45 @@ export function useWidgetData( // rather than crash on the property accesses below. throw new Error(`Unsupported widget resourceType: ${resourceType}`); } - const res = await api.post< - { - filters: Record; - pagination: { offset: number; limit: number }; - sortBy?: Record; - }, - Record - >(config.searchEndpoint, { - filters: resolvedFilters, - pagination: { offset: effectiveOffset, limit }, - ...(effectiveSortBy ? { sortBy: effectiveSortBy } : {}), + // Gated behind a shared concurrency slot (see widgetFetchConcurrency.ts) + // so an N-widget dashboard doesn't fire N simultaneous searches at + // customer-entity-service — the search call itself, not this + // queryFn's synchronous config check above, is what actually hits + // the network. The provided `signal` is wired to `api.post`'s own + // `signal` option so widgetFetchConcurrency's own timeout can + // actually abort this specific in-flight request, not just start a + // timer nothing observes. + return withWidgetFetchSlot(async (signal) => { + const res = await api.post< + { + filters: Record; + pagination: { offset: number; limit: number }; + sortBy?: Record; + }, + Record + >( + config.searchEndpoint, + { + filters: resolvedFilters, + pagination: { offset: effectiveOffset, limit }, + ...(effectiveSortBy ? { sortBy: effectiveSortBy } : {}), + }, + { signal }, + ); + const total = typeof res.total === "number" ? res.total : 0; + const rawItems = res[config.itemsKey]; + const items = Array.isArray(rawItems) + ? (rawItems as Record[]) + : []; + return { total, items }; }); - const total = typeof res.total === "number" ? res.total : 0; - const rawItems = res[config.itemsKey]; - const items = Array.isArray(rawItems) - ? (rawItems as Record[]) - : []; - return { total, items }; }, + // Explicit per-query retry (not inherited from AppWithConfig's global + // default) so a widget whose fetch timed out gets one retry — see + // shouldRetryWidgetFetch's own doc comment for why a timeout must not + // be a same-tick terminal failure, and why the retry needs no separate + // "back of the queue" bookkeeping of its own. + retry: shouldRetryWidgetFetch, staleTime: 60_000, }); } diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetPieData.test.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetPieData.test.tsx index 2ada92aeb5..bbe5fecb35 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetPieData.test.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetPieData.test.tsx @@ -60,14 +60,22 @@ describe("useWidgetPieData", () => { await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(postMock).toHaveBeenCalledTimes(2); - expect(postMock).toHaveBeenCalledWith("/cases/search", { - filters: { states: ["open"], severities: "critical" }, - pagination: { offset: 0, limit: 1 }, - }); - expect(postMock).toHaveBeenCalledWith("/cases/search", { - filters: { states: ["open"], severities: "high" }, - pagination: { offset: 0, limit: 1 }, - }); + expect(postMock).toHaveBeenCalledWith( + "/cases/search", + { + filters: { states: ["open"], severities: "critical" }, + pagination: { offset: 0, limit: 1 }, + }, + { signal: expect.any(AbortSignal) }, + ); + expect(postMock).toHaveBeenCalledWith( + "/cases/search", + { + filters: { states: ["open"], severities: "high" }, + pagination: { offset: 0, limit: 1 }, + }, + { signal: expect.any(AbortSignal) }, + ); expect(result.current.slices).toEqual([ { label: "Critical", query: { severities: "critical" }, value: 1 }, { label: "High", query: { severities: "high" }, value: 3 }, @@ -110,19 +118,23 @@ describe("useWidgetPieData", () => { await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(postMock).toHaveBeenCalledWith("/cases/search", { - filters: { - filters: [ - { field: "state", op: "in", values: ["open"] }, - { - field: "integrationCsTeam", - op: "in", - values: ["22222222-2222-2222-2222-222222222222"], - }, - ], + expect(postMock).toHaveBeenCalledWith( + "/cases/search", + { + filters: { + filters: [ + { field: "state", op: "in", values: ["open"] }, + { + field: "integrationCsTeam", + op: "in", + values: ["22222222-2222-2222-2222-222222222222"], + }, + ], + }, + pagination: { offset: 0, limit: 1 }, }, - pagination: { offset: 0, limit: 1 }, - }); + { signal: expect.any(AbortSignal) }, + ); }); it("drops the integrationCsTeam entry rather than sending the literal placeholder when no team groupId is selected", async () => { @@ -151,12 +163,16 @@ describe("useWidgetPieData", () => { await waitFor(() => expect(postMock).toHaveBeenCalled()); - expect(postMock).toHaveBeenCalledWith("/cases/search", { - filters: { - filters: [{ field: "state", op: "in", values: ["open"] }], + expect(postMock).toHaveBeenCalledWith( + "/cases/search", + { + filters: { + filters: [{ field: "state", op: "in", values: ["open"] }], + }, + pagination: { offset: 0, limit: 1 }, }, - pagination: { offset: 0, limit: 1 }, - }); + { signal: expect.any(AbortSignal) }, + ); }); it("resolves __current_user__ (in either the base or a slice's own filters) after merging, using the signed-in user's own id", async () => { @@ -186,19 +202,23 @@ describe("useWidgetPieData", () => { await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(postMock).toHaveBeenCalledWith("/cases/search", { - filters: { - filters: [ - { field: "state", op: "in", values: ["open"] }, - { - field: "assignedUserId", - op: "in", - values: ["11111111-aaaa-bbbb-cccc-000000000001"], - }, - ], + expect(postMock).toHaveBeenCalledWith( + "/cases/search", + { + filters: { + filters: [ + { field: "state", op: "in", values: ["open"] }, + { + field: "assignedUserId", + op: "in", + values: ["11111111-aaaa-bbbb-cccc-000000000001"], + }, + ], + }, + pagination: { offset: 0, limit: 1 }, }, - pagination: { offset: 0, limit: 1 }, - }); + { signal: expect.any(AbortSignal) }, + ); }); it("issues no slice search at all while the signed-in user isn't known yet, rather than one without the assignedUserId entry", async () => { diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetPieData.ts b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetPieData.ts index 801e91383a..399f5e6ce2 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetPieData.ts +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetPieData.ts @@ -26,6 +26,10 @@ import { hasCurrentUserPlaceholder, resolveCurrentUserPlaceholder, } from "@features/csm-dashboard/utils/currentUserFilterPlaceholder"; +import { + shouldRetryWidgetFetch, + withWidgetFetchSlot, +} from "@features/csm-dashboard/utils/widgetFetchConcurrency"; export interface PieSliceResult extends BeDashboardPieSlice { value: number; @@ -65,6 +69,14 @@ export function useWidgetPieData( * same as `selectedTeamGroupId`, since a slice's own `query` may carry the * placeholder too, not just the widget's base `query`. */ currentUserId?: string, + /** Set to `false` to hold every slice query without firing it — used by + * `DashboardWidgetTile` to defer a pie/bar widget's fetch until its tile + * has actually scrolled into (or near) the viewport (see + * `useElementVisibleOnce`). Defaults to `true` (fires immediately, same + * as before this parameter existed) so `DashboardWidgetPreviewPage`-style + * callers that always render one widget full-page — never lazily — don't + * need to pass anything. */ + enabled = true, ): WidgetPieData { const api = useBackendApi(); const config = WIDGET_RESOURCE_CONFIG[resourceType]; @@ -103,22 +115,41 @@ export function useWidgetPieData( if (!config) { throw new Error(`Unsupported widget resourceType: ${resourceType}`); } - const res = await api.post< - { filters: Record; pagination: { offset: number; limit: number } }, - Record - >(config.searchEndpoint, { - filters, - pagination: { offset: 0, limit: 1 }, + // Same shared concurrency slot (and timeout) useWidgetData's + // search uses — a pie widget fires one call per slice on top of + // every other widget's own call, so it needs both at least as + // much. + return withWidgetFetchSlot(async (signal) => { + const res = await api.post< + { filters: Record; pagination: { offset: number; limit: number } }, + Record + >( + config.searchEndpoint, + { + filters, + pagination: { offset: 0, limit: 1 }, + }, + { signal }, + ); + return typeof res.total === "number" ? res.total : 0; }); - return typeof res.total === "number" ? res.total : 0; }, - enabled: !awaitingCurrentUser, + enabled: enabled && !awaitingCurrentUser, + // Same per-query retry override as useWidgetData, same reasoning + // (see shouldRetryWidgetFetch) — a pie/bar slice fetch that timed + // out gets one retry too. + retry: shouldRetryWidgetFetch, staleTime: 60_000, }; }), }); - const isLoading = awaitingCurrentUser || queries.some((q) => q.isLoading); + // `!enabled` (still waiting to scroll into view) reports as loading + // rather than as react-query's own `isLoading` for a disabled query + // (which is `false` — a query that never started isn't "loading" to + // react-query) — this hook's own `isLoading` is a widget-level "don't + // paint real data yet" signal, not a passthrough of query-fetch state. + const isLoading = !enabled || awaitingCurrentUser || queries.some((q) => q.isLoading); const isError = queries.some((q) => q.isError); const results: PieSliceResult[] = slices.map((slice, i) => ({ ...slice, diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetGrid.lazyLoad.test.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetGrid.lazyLoad.test.tsx new file mode 100644 index 0000000000..0a5f216473 --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetGrid.lazyLoad.test.tsx @@ -0,0 +1,184 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * Proves the viewport-gated (lazy) loading behaviour end to end, through + * the real fan-out component (`DashboardWidgetGrid`, one `.map()` over + * every widget) rather than only unit-testing `useElementVisibleOnce` in + * isolation: mounts a dashboard's worth of widgets under a mocked + * `IntersectionObserver`, asserts only the ones reported as already + * intersecting fetch immediately, then simulates the browser reporting an + * off-screen one as newly intersecting (as it would once scrolled near the + * viewport) and asserts THAT one fetches only at that point, not before. + */ + +import { act, render, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { MemoryRouter } from "react-router"; +import type { BeDashboardWidget } from "@api/backend/types"; + +const postMock = vi.fn(); + +vi.mock("@api/backend/client", () => ({ + useBackendApi: () => ({ post: postMock }), +})); +vi.mock("@config/apiConfig", () => ({ + apiConfig: { backendUrl: "https://example.test" }, +})); +vi.mock("@context/current-user/CurrentUserContext", () => ({ + useCurrentUser: () => ({ + user: { id: "11111111-aaaa-bbbb-cccc-000000000001" }, + isLoading: false, + isError: false, + }), +})); + +import DashboardWidgetGrid from "@features/csm-dashboard/components/DashboardWidgetGrid"; + +/** Same rationale as `useElementVisibleOnce.test.ts` — jsdom has no + * `IntersectionObserver`, and this codebase has no existing mock for it, so + * this is the first. Each observed node can be told "yes"/"no" individually + * (real browser behaviour: only the entries that actually crossed the + * threshold are reported), which is what lets this test simulate "some + * widgets start in view, one starts out of view, then scrolls in". */ +class FakeIntersectionObserver { + static instances: FakeIntersectionObserver[] = []; + callback: IntersectionObserverCallback; + observedNodes = new Set(); + + constructor(callback: IntersectionObserverCallback) { + this.callback = callback; + FakeIntersectionObserver.instances.push(this); + } + + observe(node: Element): void { + this.observedNodes.add(node); + } + + unobserve(node: Element): void { + this.observedNodes.delete(node); + } + + disconnect(): void { + this.observedNodes.clear(); + } + + /** Fires only for `node` — mirrors the real API, which only reports + * entries whose intersection state actually changed. */ + report(node: Element, isIntersecting: boolean): void { + if (!this.observedNodes.has(node)) return; + this.callback( + [{ isIntersecting, target: node } as IntersectionObserverEntry], + this as unknown as IntersectionObserver, + ); + } +} + +function widget(id: string, resourceType: BeDashboardWidget["resourceType"] = "case"): BeDashboardWidget { + return { + widgetId: id, + displayName: id, + resourceType, + shape: "count", + gridWidth: 3, + query: {}, + }; +} + +function renderGrid(widgets: BeDashboardWidget[]) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + + , + ); +} + +describe("DashboardWidgetGrid lazy widget loading", () => { + const originalIntersectionObserver = globalThis.IntersectionObserver; + + beforeEach(() => { + postMock.mockReset(); + postMock.mockResolvedValue({ total: 1, cases: [], limit: 1, offset: 0, hasMore: false }); + FakeIntersectionObserver.instances = []; + (globalThis as { IntersectionObserver?: unknown }).IntersectionObserver = + FakeIntersectionObserver; + }); + + afterEach(() => { + (globalThis as { IntersectionObserver?: unknown }).IntersectionObserver = + originalIntersectionObserver; + }); + + it("only fetches widgets already intersecting on mount; an off-screen widget fetches only once it later intersects", async () => { + const widgets = [widget("above_fold_1"), widget("above_fold_2"), widget("below_fold")]; + const { container } = renderGrid(widgets); + + // Every tile registers its own observer instance (one ref each). + await waitFor(() => expect(FakeIntersectionObserver.instances).toHaveLength(3)); + + const cards = Array.from(container.querySelectorAll(".MuiCard-root")); + expect(cards).toHaveLength(3); + + // Report the first two as already in view (as they'd be on a real + // mount where they're above the fold) and leave the third alone — + // exactly the "some widgets visible immediately, one is not" case. + act(() => { + FakeIntersectionObserver.instances[0].report(cards[0], true); + FakeIntersectionObserver.instances[1].report(cards[1], true); + }); + + await waitFor(() => expect(postMock).toHaveBeenCalledTimes(2)); + + const fetchedIds = postMock.mock.calls.map(([, body]) => body); + expect(fetchedIds).toHaveLength(2); + // Give the (deliberately un-triggered) third tile a chance to have + // fired if the gating didn't hold — it must still not have. + expect(postMock).toHaveBeenCalledTimes(2); + + // Now simulate the user scrolling the third widget into view. + act(() => { + FakeIntersectionObserver.instances[2].report(cards[2], true); + }); + + await waitFor(() => expect(postMock).toHaveBeenCalledTimes(3)); + }); + + it("keeps a widget's data loaded once fetched, even if it were to report leaving the viewport again", async () => { + const widgets = [widget("single")]; + const { container } = renderGrid(widgets); + + await waitFor(() => expect(FakeIntersectionObserver.instances).toHaveLength(1)); + const card = container.querySelector(".MuiCard-root") as Element; + + act(() => { + FakeIntersectionObserver.instances[0].report(card, true); + }); + await waitFor(() => expect(postMock).toHaveBeenCalledTimes(1)); + + // A real browser wouldn't even report this (the observer disconnects + // itself on first intersection — see useElementVisibleOnce), but even + // if it did, this hook's "visible once" latch must not un-fetch. + act(() => { + FakeIntersectionObserver.instances[0].report(card, false); + }); + + expect(postMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetGrid.realisticViewport.test.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetGrid.realisticViewport.test.tsx new file mode 100644 index 0000000000..a45e78150f --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetGrid.realisticViewport.test.tsx @@ -0,0 +1,217 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * Regression test for the live abt-engineer report: with the original + * `rootMargin: "200px"` / implicit `threshold: 0`, only 1-2 widgets were + * actually on screen when the dashboard opened, but 4 fired their fetch. + * `useElementVisibleOnce.test.ts` covers the hook's own options in + * isolation; this file proves the effect at the scale that actually + * exposed the bug — a dashboard's worth of widgets (~20, abt-engineer's + * own real count per this task's earlier investigation) in a real grid + * layout — by simulating real element geometry (row position vs. a + * simulated viewport height) instead of manually flipping + * `isIntersecting` per tile as the other lazy-load test file does. + * + * Caveat, stated plainly rather than implied: this repo has no local copy + * of abt-engineer's actual `DASHBOARDS_CONFIG` (it's backend-owned/env-var + * config, not checked into this webapp, and pulling the live value would + * mean hitting Choreo/prod, out of scope for a frontend test) and jsdom + * has no real layout engine, so `getBoundingClientRect` on real rendered + * nodes is useless here regardless. The geometry below (4 count-shape + * tiles per row, 160px row pitch, 350px "above the fold" viewport height) + * is a representative stand-in sized to plausibly match a real dashboard, + * not a literal reproduction of the live numbers — what this test actually + * proves is the mechanism: given a widget's real on-screen position, the + * FIXED hook (0px margin, 0.25 threshold) only fires for widgets with + * meaningful on-screen overlap, and demonstrably fewer of them than the + * OLD settings would have. The live dev-server re-check is what confirms + * the actual abt-engineer numbers. + */ + +import { render, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { MemoryRouter } from "react-router"; +import type { BeDashboardWidget } from "@api/backend/types"; + +const postMock = vi.fn(); + +vi.mock("@api/backend/client", () => ({ + useBackendApi: () => ({ post: postMock }), +})); +vi.mock("@config/apiConfig", () => ({ + apiConfig: { backendUrl: "https://example.test" }, +})); +vi.mock("@context/current-user/CurrentUserContext", () => ({ + useCurrentUser: () => ({ + user: { id: "11111111-aaaa-bbbb-cccc-000000000001" }, + isLoading: false, + isError: false, + }), +})); + +import DashboardWidgetGrid from "@features/csm-dashboard/components/DashboardWidgetGrid"; + +// --- Simulated layout ------------------------------------------------- +// 4 widgets per row (gridWidth 3 of 12 — a common count-shape tile width +// on the real dashboards), pitch 160px per row (card height + grid gap), +// 20 total widgets (abt-engineer's own approximate count per this task's +// earlier prod-log investigation) — 5 rows. "Above the fold" viewport +// height 350px models a realistic content area once the app's own +// header/nav/dashboard-header chrome is subtracted from a normal laptop +// window. +const WIDGETS_PER_ROW = 4; +const ROW_HEIGHT_PX = 160; +const TOTAL_WIDGETS = 20; +const VIEWPORT_HEIGHT_PX = 350; + +function rowTop(index: number): number { + return Math.floor(index / WIDGETS_PER_ROW) * ROW_HEIGHT_PX; +} + +/** Same overlap math the fake observer below uses per-node — exposed + * separately so this file can assert the OLD-vs-NEW magnitude difference + * as plain arithmetic, not just implicitly through the component mount. */ +function isVisible(index: number, marginPx: number, threshold: number): boolean { + const top = rowTop(index); + const bottom = top + ROW_HEIGHT_PX; + const rootTop = 0 - marginPx; + const rootBottom = VIEWPORT_HEIGHT_PX + marginPx; + const overlap = Math.max(0, Math.min(bottom, rootBottom) - Math.max(top, rootTop)); + const ratio = overlap / ROW_HEIGHT_PX; + return overlap > 0 && ratio >= threshold; +} + +function countVisible(marginPx: number, threshold: number): number { + let count = 0; + for (let i = 0; i < TOTAL_WIDGETS; i += 1) { + if (isVisible(i, marginPx, threshold)) count += 1; + } + return count; +} + +/** Computes real intersection geometry from each observed node's position + * in `DashboardWidgetGrid`'s own render order (widgets mount in array + * order, and their effects — which call `observe()` — run in that same + * order; this is the assumption that lets index-based rects work without + * jsdom's nonexistent real layout) rather than requiring a test to flip + * `isIntersecting` by hand per tile, which is what makes this file able to + * exercise all 20 widgets' worth of geometry at once. */ +class GeometryIntersectionObserver { + static instances: GeometryIntersectionObserver[] = []; + static nextObserveIndex = 0; + + callback: IntersectionObserverCallback; + marginPx: number; + threshold: number; + + constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) { + this.callback = callback; + const match = /^(-?\d+)px$/.exec(String(options?.rootMargin ?? "0px").trim()); + this.marginPx = match ? Number(match[1]) : 0; + this.threshold = typeof options?.threshold === "number" ? options.threshold : 0; + GeometryIntersectionObserver.instances.push(this); + } + + observe(node: Element): void { + const index = GeometryIntersectionObserver.nextObserveIndex; + GeometryIntersectionObserver.nextObserveIndex += 1; + const visible = isVisible(index, this.marginPx, this.threshold); + this.callback( + [{ isIntersecting: visible, target: node } as IntersectionObserverEntry], + this as unknown as IntersectionObserver, + ); + } + + unobserve(): void {} + disconnect(): void {} +} + +function widget(index: number): BeDashboardWidget { + return { + widgetId: `widget_${index}`, + displayName: `Widget ${index}`, + resourceType: "case", + shape: "count", + gridWidth: 3, // 12 / 3 = 4 per row, matching WIDGETS_PER_ROW above. + query: {}, + }; +} + +function renderGrid(widgets: BeDashboardWidget[]) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + + , + ); +} + +describe("DashboardWidgetGrid lazy loading at realistic dashboard scale", () => { + const originalIntersectionObserver = globalThis.IntersectionObserver; + + beforeEach(() => { + postMock.mockReset(); + postMock.mockResolvedValue({ total: 1, cases: [], limit: 1, offset: 0, hasMore: false }); + GeometryIntersectionObserver.instances = []; + GeometryIntersectionObserver.nextObserveIndex = 0; + (globalThis as { IntersectionObserver?: unknown }).IntersectionObserver = + GeometryIntersectionObserver; + }); + + afterEach(() => { + (globalThis as { IntersectionObserver?: unknown }).IntersectionObserver = + originalIntersectionObserver; + }); + + it("documents the old-vs-new magnitude: 200px/threshold-0 over-fires relative to what's genuinely on screen, 0px/0.25 does not", () => { + // Genuinely on screen: rows whose full height fits within the 350px + // viewport with no margin at all — the ground truth a human looking at + // the screen would report. + const genuinelyVisible = countVisible(0, 1); // threshold 1 == "fully on screen" + expect(genuinelyVisible).toBe(8); // rows 0-1 (4 widgets each) fully fit; row 2 does not. + + const oldConfig = countVisible(200, 0); + const newConfig = countVisible(0, 0.25); + + expect(oldConfig).toBe(16); // rows 0-2 fully "inside" the 200px-extended root, row 3 partially — this is the over-fetch bug. + expect(newConfig).toBe(8); // matches genuinelyVisible exactly: rows 0-1 only. + expect(newConfig).toBeLessThan(oldConfig); + }); + + it("the real DashboardWidgetGrid, with the fixed hook's actual default options, fetches only the widgets with meaningful on-screen overlap — not more", async () => { + const widgets = Array.from({ length: TOTAL_WIDGETS }, (_, i) => widget(i)); + renderGrid(widgets); + + await waitFor(() => + expect(GeometryIntersectionObserver.instances).toHaveLength(TOTAL_WIDGETS), + ); + + // Every widget's own useElementVisibleOnce call goes through this fake + // observer with the hook's REAL default options (rootMargin "0px", + // threshold 0.25) — nothing in this test hardcodes those values itself, + // so a regression back to "200px"/no-threshold would change this + // assertion's outcome, not just the isolated hook-options test. + await waitFor(() => expect(postMock).toHaveBeenCalledTimes(8)); + + // Give any wrongly-gated widget a tick to have fired anyway. + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(postMock).toHaveBeenCalledTimes(8); + }); +}); diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.test.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.test.tsx index 8e9a37b6b6..bb60a7d247 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.test.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.test.tsx @@ -112,6 +112,7 @@ vi.mock("@wso2/oxygen-ui-charts-react", () => ({ import DashboardWidgetTile from "@features/csm-dashboard/components/DashboardWidgetTile"; import { CURRENT_TEAM_PLACEHOLDER } from "@features/csm-dashboard/utils/teamFilterPlaceholder"; import { CURRENT_USER_PLACEHOLDER } from "@features/csm-dashboard/utils/currentUserFilterPlaceholder"; +import { __resetWidgetFetchConcurrencyForTests } from "@features/csm-dashboard/utils/widgetFetchConcurrency"; function renderWithClient(ui: ReactNode) { const queryClient = new QueryClient({ @@ -156,6 +157,14 @@ describe("DashboardWidgetTile", () => { beforeEach(() => { postMock.mockReset(); mockCurrentUserId = SIGNED_IN_USER_ID; + // The concurrency semaphore (widgetFetchConcurrency.ts) is a + // module-level singleton shared across this whole file — a test below + // deliberately leaves its own fetch pending forever + // (`new Promise(() => {})`) to assert a loading state, which would + // otherwise permanently hold its slot and starve every later test in + // this file (fatal at WIDGET_FETCH_CONCURRENCY_LIMIT === 1, since + // there is then nothing left to acquire). + __resetWidgetFetchConcurrencyForTests(); }); it("renders a skeleton while its own count is in flight", () => { @@ -187,10 +196,14 @@ describe("DashboardWidgetTile", () => { await waitFor(() => expect(screen.getByText("3")).toBeInTheDocument()); expect(screen.getByText("My Patches")).toBeInTheDocument(); - expect(postMock).toHaveBeenCalledWith("/cases/search", { - filters: {}, - pagination: { offset: 0, limit: 1 }, - }); + expect(postMock).toHaveBeenCalledWith( + "/cases/search", + { + filters: {}, + pagination: { offset: 0, limit: 1 }, + }, + { signal: expect.any(AbortSignal) }, + ); }); it("renders its own error state when its /cases/search call fails", async () => { @@ -284,18 +297,22 @@ describe("DashboardWidgetTile", () => { await waitFor(() => expect(screen.getByText("1")).toBeInTheDocument()); // "11111111-aaaa-bbbb-cccc-000000000001" is the mocked signed-in user's // own id (see the CurrentUserContext mock above). - expect(postMock).toHaveBeenCalledWith("/cases/search", { - filters: { - filters: [ - { - field: "assignedUserId", - op: "in", - values: ["11111111-aaaa-bbbb-cccc-000000000001"], - }, - ], + expect(postMock).toHaveBeenCalledWith( + "/cases/search", + { + filters: { + filters: [ + { + field: "assignedUserId", + op: "in", + values: ["11111111-aaaa-bbbb-cccc-000000000001"], + }, + ], + }, + pagination: { offset: 0, limit: 1 }, }, - pagination: { offset: 0, limit: 1 }, - }); + { signal: expect.any(AbortSignal) }, + ); }); it("renders the same table the Cases tab uses for shape: list, capped at listLimit", async () => { @@ -330,10 +347,14 @@ describe("DashboardWidgetTile", () => { expect(screen.getByText("Disk full")).toBeInTheDocument(); expect(screen.getByText("CS-2")).toBeInTheDocument(); expect(screen.getByText("Auth failing")).toBeInTheDocument(); - expect(postMock).toHaveBeenCalledWith("/cases/search", { - filters: {}, - pagination: { offset: 0, limit: 5 }, - }); + expect(postMock).toHaveBeenCalledWith( + "/cases/search", + { + filters: {}, + pagination: { offset: 0, limit: 5 }, + }, + { signal: expect.any(AbortSignal) }, + ); }); it("shape list: shows the widget's own total count, not just the capped row count shown below it", async () => { @@ -649,11 +670,15 @@ describe("DashboardWidgetTile", () => { ); await waitFor(() => - expect(postMock).toHaveBeenCalledWith("/cases/search", { - filters: {}, - pagination: { offset: 0, limit: 5 }, - sortBy: { field: "updatedOn", order: "asc" }, - }), + expect(postMock).toHaveBeenCalledWith( + "/cases/search", + { + filters: {}, + pagination: { offset: 0, limit: 5 }, + sortBy: { field: "updatedOn", order: "asc" }, + }, + { signal: expect.any(AbortSignal) }, + ), ); }); @@ -672,10 +697,14 @@ describe("DashboardWidgetTile", () => { ); await waitFor(() => expect(screen.getByText("3")).toBeInTheDocument()); - expect(postMock).toHaveBeenCalledWith("/cases/search", { - filters: {}, - pagination: { offset: 0, limit: 1 }, - }); + expect(postMock).toHaveBeenCalledWith( + "/cases/search", + { + filters: {}, + pagination: { offset: 0, limit: 1 }, + }, + { signal: expect.any(AbortSignal) }, + ); }); it("navigates to /cases with translated filters when a case-resource tile is clicked", async () => { @@ -814,18 +843,22 @@ describe("DashboardWidgetTile", () => { ); await waitFor(() => expect(screen.getByText("3")).toBeInTheDocument()); - expect(postMock).toHaveBeenCalledWith("/cases/search", { - filters: { - filters: [ - { - field: "integrationCsTeam", - op: "in", - values: ["22222222-2222-2222-2222-222222222222"], - }, - ], + expect(postMock).toHaveBeenCalledWith( + "/cases/search", + { + filters: { + filters: [ + { + field: "integrationCsTeam", + op: "in", + values: ["22222222-2222-2222-2222-222222222222"], + }, + ], + }, + pagination: { offset: 0, limit: 1 }, }, - pagination: { offset: 0, limit: 1 }, - }); + { signal: expect.any(AbortSignal) }, + ); }); it("re-fetches with the new team's own filters when selectedTeamGroupId changes (team switch must not reuse a stale cached query)", async () => { @@ -858,10 +891,14 @@ describe("DashboardWidgetTile", () => { ); await waitFor(() => expect(screen.getByText("3")).toBeInTheDocument()); - expect(postMock).toHaveBeenLastCalledWith("/cases/search", { - filters: { filters: [{ field: "integrationCsTeam", op: "in", values: ["team-a-group-id"] }] }, - pagination: { offset: 0, limit: 1 }, - }); + expect(postMock).toHaveBeenLastCalledWith( + "/cases/search", + { + filters: { filters: [{ field: "integrationCsTeam", op: "in", values: ["team-a-group-id"] }] }, + pagination: { offset: 0, limit: 1 }, + }, + { signal: expect.any(AbortSignal) }, + ); postMock.mockResolvedValueOnce({ total: 9, cases: [], limit: 1, offset: 0, hasMore: false }); @@ -881,10 +918,14 @@ describe("DashboardWidgetTile", () => { ); await waitFor(() => expect(screen.getByText("9")).toBeInTheDocument()); - expect(postMock).toHaveBeenLastCalledWith("/cases/search", { - filters: { filters: [{ field: "integrationCsTeam", op: "in", values: ["team-b-group-id"] }] }, - pagination: { offset: 0, limit: 1 }, - }); + expect(postMock).toHaveBeenLastCalledWith( + "/cases/search", + { + filters: { filters: [{ field: "integrationCsTeam", op: "in", values: ["team-b-group-id"] }] }, + pagination: { offset: 0, limit: 1 }, + }, + { signal: expect.any(AbortSignal) }, + ); }); it("drops the integrationCsTeam filter (request and href) rather than sending the literal placeholder when no team groupId is selected", async () => { @@ -906,10 +947,14 @@ describe("DashboardWidgetTile", () => { ); await waitFor(() => expect(screen.getByText("3")).toBeInTheDocument()); - expect(postMock).toHaveBeenCalledWith("/cases/search", { - filters: { filters: [{ field: "state", op: "in", values: ["open"] }] }, - pagination: { offset: 0, limit: 1 }, - }); + expect(postMock).toHaveBeenCalledWith( + "/cases/search", + { + filters: { filters: [{ field: "state", op: "in", values: ["open"] }] }, + pagination: { offset: 0, limit: 1 }, + }, + { signal: expect.any(AbortSignal) }, + ); const link = screen.getByRole("link"); expect(link.getAttribute("href") ?? "").not.toContain(CURRENT_TEAM_PLACEHOLDER); @@ -983,15 +1028,19 @@ describe("DashboardWidgetTile", () => { await waitFor(() => expect(screen.getByText("bar:S1 · Critical:1")).toBeInTheDocument()); expect(screen.getByText("bar:S2 · High:3")).toBeInTheDocument(); - expect(postMock).toHaveBeenCalledWith("/cases/search", { - filters: { - filters: [ - { field: "state", op: "in", values: ["open"] }, - { field: "severity", op: "in", values: ["critical"] }, - ], + expect(postMock).toHaveBeenCalledWith( + "/cases/search", + { + filters: { + filters: [ + { field: "state", op: "in", values: ["open"] }, + { field: "severity", op: "in", values: ["critical"] }, + ], + }, + pagination: { offset: 0, limit: 1 }, }, - pagination: { offset: 0, limit: 1 }, - }); + { signal: expect.any(AbortSignal) }, + ); fireEvent.click(screen.getByText("bar:S1 · Critical:1")); await waitFor(() => expect(screen.getByTestId("location-probe")).toBeInTheDocument()); @@ -1073,24 +1122,32 @@ describe("DashboardWidgetTile", () => { expect(screen.getByText("slice:S2 · High:3")).toBeInTheDocument(); expect(screen.getByText("1 (25%)")).toBeInTheDocument(); expect(screen.getByText("3 (75%)")).toBeInTheDocument(); - expect(postMock).toHaveBeenCalledWith("/cases/search", { - filters: { - filters: [ - { field: "state", op: "in", values: ["open"] }, - { field: "severity", op: "in", values: ["critical"] }, - ], + expect(postMock).toHaveBeenCalledWith( + "/cases/search", + { + filters: { + filters: [ + { field: "state", op: "in", values: ["open"] }, + { field: "severity", op: "in", values: ["critical"] }, + ], + }, + pagination: { offset: 0, limit: 1 }, }, - pagination: { offset: 0, limit: 1 }, - }); - expect(postMock).toHaveBeenCalledWith("/cases/search", { - filters: { - filters: [ - { field: "state", op: "in", values: ["open"] }, - { field: "severity", op: "in", values: ["high"] }, - ], + { signal: expect.any(AbortSignal) }, + ); + expect(postMock).toHaveBeenCalledWith( + "/cases/search", + { + filters: { + filters: [ + { field: "state", op: "in", values: ["open"] }, + { field: "severity", op: "in", values: ["high"] }, + ], + }, + pagination: { offset: 0, limit: 1 }, }, - pagination: { offset: 0, limit: 1 }, - }); + { signal: expect.any(AbortSignal) }, + ); }); it("shape pie: clicking a slice navigates to /cases with the widget's base filters merged under that slice's own filters", async () => { diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.tsx index e2e5e2d99a..77a4507fb6 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.tsx @@ -16,8 +16,9 @@ import { Box, Button, Card, Chip, IconButton, Skeleton, Tooltip, Typography, alpha, useTheme } from "@wso2/oxygen-ui"; import { ArrowRight, Info } from "@wso2/oxygen-ui-icons-react"; -import type { JSX, KeyboardEvent, ReactNode } from "react"; +import { useRef, type JSX, type KeyboardEvent, type ReactNode } from "react"; import { Link as RouterLink, useLocation, useNavigate } from "react-router"; +import { useElementVisibleOnce } from "@hooks/useElementVisibleOnce"; import type { BeDashboardPieSlice, BeDashboardWidgetColumn, @@ -128,6 +129,15 @@ export default function DashboardWidgetTile({ const location = useLocation(); const { user } = useCurrentUser(); const currentUserId = user?.id; + // Gates this tile's own data fetch (below) behind having actually + // scrolled into (or near — see the hook's rootMargin) the viewport at + // least once, rather than every widget on the dashboard firing its + // search the instant the page mounts. Attached to whichever top-level + // `Card` this component ends up returning (every branch below sets + // `ref={tileRef}`) — a given tile's shape never changes across its own + // lifetime, so exactly one of them ever mounts for a given instance. + const tileRef = useRef(null); + const isVisible = useElementVisibleOnce(tileRef); // Resolves every client-side filter placeholder a widget's own (opaque) // filters may carry — `__current_team__` (see `teamFilterPlaceholder.ts`), // `__current_user__` (see `currentUserFilterPlaceholder.ts`) and the @@ -179,7 +189,7 @@ export default function DashboardWidgetTile({ shape, listLimit, 0, - shape !== "pie" && shape !== "bar", + shape !== "pie" && shape !== "bar" && isVisible, selectedTeamGroupId, sortBy, currentUserId, @@ -191,6 +201,7 @@ export default function DashboardWidgetTile({ shape === "pie" || shape === "bar" ? (slices ?? []) : [], selectedTeamGroupId, currentUserId, + isVisible, ); // True while this widget carries a `__current_user__` filter and the // signed-in user's profile hasn't loaded yet. `useWidgetData`/ @@ -200,7 +211,13 @@ export default function DashboardWidgetTile({ // tile must present the wait as loading rather than paint the `0` a // deferred query's absent data would otherwise resolve to. const awaitingCurrentUser = currentUserId === undefined && hasCurrentUserPlaceholder(filters); - const isLoading = isWidgetDataLoading || awaitingCurrentUser; + // `!isVisible` (this tile hasn't scrolled into view yet, so its own fetch + // above never fired) reports as loading rather than as react-query's own + // `isLoading` for a disabled query — same rationale as useWidgetPieData's + // own `isLoading`. This is the count/list-shape tile's loading state; + // shape "pie"/"bar" reads `pieData.isLoading` instead, which already + // folds in the same `isVisible` gate via the `enabled` argument above. + const isLoading = isWidgetDataLoading || awaitingCurrentUser || !isVisible; const config = WIDGET_RESOURCE_CONFIG[resourceType]; // Thousands separators for shape "count"'s big number -- used both in the // visible Typography and the tile's aria-label, so both stay in sync. @@ -211,7 +228,7 @@ export default function DashboardWidgetTile({ // compile-time-checked Go literal) — an unrecognized value must not // crash this tile's render (config.buildHref below would throw). return ( - + {resolvedDisplayName} @@ -324,7 +341,7 @@ export default function DashboardWidgetTile({ const ListRenderer = WIDGET_LIST_RENDERERS[resourceType]; const total = data?.total ?? 0; return ( - + {/* The rows below are capped at `listLimit` (see `useWidgetData`'s DEFAULT_LIST_LIMIT) — this badge next to the title is the only place the widget's own full count is visible, since "View more" @@ -419,6 +436,7 @@ export default function DashboardWidgetTile({ }; return ( { expect(screen.getByText("My Critical & High Cases")).toBeInTheDocument(); await waitFor(() => expect(screen.getByText("CS-1")).toBeInTheDocument()); - expect(postMock).toHaveBeenCalledWith("/cases/search", { - filters: { severities: ["critical"] }, - pagination: { offset: 0, limit: 10 }, - }); + expect(postMock).toHaveBeenCalledWith( + "/cases/search", + { + filters: { severities: ["critical"] }, + pagination: { offset: 0, limit: 10 }, + }, + { signal: expect.any(AbortSignal) }, + ); // TablePagination's "next page" button. fireEvent.click(screen.getByRole("button", { name: /next page/i })); await waitFor(() => - expect(postMock).toHaveBeenCalledWith("/cases/search", { - filters: { severities: ["critical"] }, - pagination: { offset: 10, limit: 10 }, - }), + expect(postMock).toHaveBeenCalledWith( + "/cases/search", + { + filters: { severities: ["critical"] }, + pagination: { offset: 10, limit: 10 }, + }, + { signal: expect.any(AbortSignal) }, + ), ); }); @@ -140,10 +148,14 @@ describe("DashboardWidgetPreviewPage", () => { ); await waitFor(() => expect(screen.getByText("CS-1")).toBeInTheDocument()); - expect(postMock).toHaveBeenCalledWith("/cases/search", { - filters: { assignedUserIds: [CURRENT_USER_ID] }, - pagination: { offset: 0, limit: 10 }, - }); + expect(postMock).toHaveBeenCalledWith( + "/cases/search", + { + filters: { assignedUserIds: [CURRENT_USER_ID] }, + pagination: { offset: 0, limit: 10 }, + }, + { signal: expect.any(AbortSignal) }, + ); }); it("merges a typed search term into the widget's own filters as searchQuery", async () => { @@ -168,10 +180,14 @@ describe("DashboardWidgetPreviewPage", () => { fireEvent.change(screen.getByLabelText("Search"), { target: { value: "disk" } }); await waitFor(() => - expect(postMock).toHaveBeenCalledWith("/cases/search", { - filters: { severities: ["critical"], searchQuery: "disk" }, - pagination: { offset: 0, limit: 10 }, - }), + expect(postMock).toHaveBeenCalledWith( + "/cases/search", + { + filters: { severities: ["critical"], searchQuery: "disk" }, + pagination: { offset: 0, limit: 10 }, + }, + { signal: expect.any(AbortSignal) }, + ), ); }); diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/utils/widgetFetchConcurrency.test.ts b/apps/csm-portal/webapp/src/features/csm-dashboard/utils/widgetFetchConcurrency.test.ts new file mode 100644 index 0000000000..6082c5a0fc --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/utils/widgetFetchConcurrency.test.ts @@ -0,0 +1,226 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + WIDGET_FETCH_CONCURRENCY_LIMIT, + WIDGET_FETCH_TIMEOUT_MS, + shouldRetryWidgetFetch, + withWidgetFetchSlot, +} from "@features/csm-dashboard/utils/widgetFetchConcurrency"; + +describe("withWidgetFetchSlot", () => { + it("never lets more than WIDGET_FETCH_CONCURRENCY_LIMIT calls run at once, even when far more are requested simultaneously", async () => { + const totalRequests = WIDGET_FETCH_CONCURRENCY_LIMIT * 4 + 3; // deliberately not a clean multiple + let concurrent = 0; + let peakConcurrent = 0; + const started: number[] = []; + + // Every "widget" resolves only once every one of them has requested a + // slot — the same shape as N dashboard tiles all mounting and firing + // their query at once. If the cap didn't hold, every one of these + // would run its body immediately and peakConcurrent would equal + // totalRequests. + const releaseGate = () => new Promise((resolve) => setTimeout(resolve, 5)); + + const calls = Array.from({ length: totalRequests }, (_, i) => + withWidgetFetchSlot(async () => { + started.push(i); + concurrent += 1; + peakConcurrent = Math.max(peakConcurrent, concurrent); + await releaseGate(); + concurrent -= 1; + return i; + }), + ); + + const results = await Promise.all(calls); + + expect(results).toHaveLength(totalRequests); + expect(peakConcurrent).toBeLessThanOrEqual(WIDGET_FETCH_CONCURRENCY_LIMIT); + expect(peakConcurrent).toBeGreaterThan(0); + // With a real gate every call has to wait through, the cap should + // actually bind at least once — otherwise this test would pass + // vacuously even with no cap at all if totalRequests were small. + expect(peakConcurrent).toBe(WIDGET_FETCH_CONCURRENCY_LIMIT); + // A LIFO or arbitrary-order queue would also pass every assertion above + // (peak concurrency is unaffected by ordering) — this is the one that + // actually proves the queue is FIFO, not just capped. + expect(started).toEqual(Array.from({ length: totalRequests }, (_, i) => i)); + }); + + it("releases a slot as soon as fn rejects, so a failing widget doesn't starve the queue", async () => { + const results: string[] = []; + + const failing = withWidgetFetchSlot(async () => { + throw new Error("widget search failed"); + }).catch(() => { + results.push("failed"); + }); + + // Fill every remaining slot with fast successes. + const fillers = Array.from({ length: WIDGET_FETCH_CONCURRENCY_LIMIT - 1 }, (_, i) => + withWidgetFetchSlot(async () => { + results.push(`filler-${i}`); + }), + ); + + // One more, queued past the cap — only starts once a slot frees up, + // which requires the failed call above to have released its slot. + const queued = withWidgetFetchSlot(async () => { + results.push("queued"); + }); + + await Promise.all([failing, ...fillers, queued]); + + expect(results).toContain("failed"); + expect(results).toContain("queued"); + }); + + it("returns fn's own resolved value unchanged", async () => { + const value = await withWidgetFetchSlot(async () => ({ total: 42 })); + expect(value).toEqual({ total: 42 }); + }); + + it("propagates fn's own rejection unchanged", async () => { + await expect( + withWidgetFetchSlot(async () => { + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + }); + + describe("timeout", () => { + // Pure promises/timers, no React/react-query in this describe block — + // fake timers are reliable here (verified directly: this exact + // pattern, `vi.advanceTimersByTimeAsync` + a `setTimeout`-driven + // promise, resolves correctly with no React involved; the + // React/react-query-specific unreliability that pushed the hook-level + // retry tests to real timers — see useWidgetData.test.tsx — doesn't + // apply to this module in isolation). + afterEach(() => { + vi.useRealTimers(); + }); + + it("aborts fn via the provided signal at exactly WIDGET_FETCH_TIMEOUT_MS, not before", async () => { + vi.useFakeTimers(); + let aborted = false; + + const call = withWidgetFetchSlot( + (signal) => + new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => { + aborted = true; + reject(Object.assign(new Error("aborted"), { name: "AbortError" })); + }); + }), + ); + // Swallow the eventual rejection here — asserted properly below — + // so it doesn't surface as an unhandled rejection while we're still + // advancing time toward it. + call.catch(() => {}); + + await vi.advanceTimersByTimeAsync(WIDGET_FETCH_TIMEOUT_MS - 1); + expect(aborted).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + expect(aborted).toBe(true); + await expect(call).rejects.toMatchObject({ name: "AbortError" }); + + vi.useRealTimers(); + }); + + it("releases the slot on timeout so the next queued call proceeds — at the raw semaphore level, no React involved", async () => { + vi.useFakeTimers(); + const events: string[] = []; + + withWidgetFetchSlot( + (signal) => + new Promise((_resolve, reject) => { + events.push("first-started"); + signal.addEventListener("abort", () => { + reject(Object.assign(new Error("aborted"), { name: "AbortError" })); + }); + }), + ).catch(() => { + events.push("first-rejected"); + }); + + const second = withWidgetFetchSlot(async () => { + events.push("second-started"); + return "second-result"; + }); + + await vi.advanceTimersByTimeAsync(0); + // WIDGET_FETCH_CONCURRENCY_LIMIT is 1 — the second call must still be + // queued, not started, before the first has even timed out. + expect(events).toEqual(["first-started"]); + + await vi.advanceTimersByTimeAsync(WIDGET_FETCH_TIMEOUT_MS - 1); + expect(events).toEqual(["first-started"]); + + // Crossing the deadline: the first call's slot releases, and the + // second — previously queued — call proceeds. This is the assertion + // that actually matters for this task: a timeout must not leak the + // slot the way an unresolved test-mock promise (see + // __resetWidgetFetchConcurrencyForTests) does. + await vi.advanceTimersByTimeAsync(1); + await expect(second).resolves.toBe("second-result"); + expect(events).toContain("second-started"); + + vi.useRealTimers(); + }); + }); +}); + +describe("shouldRetryWidgetFetch", () => { + /** Matches what `withWidgetFetchSlot`'s own timeout produces when it + * aborts — see that function's own `controller.abort()` call. */ + const abortError = Object.assign(new Error("The operation was aborted."), { + name: "AbortError", + }); + + it("retries once on this module's own timeout abort", () => { + // react-query calls its `retry` predicate with `failureCount` starting + // at 0 on the FIRST failure (verified directly against + // @tanstack/query-core's retryer.ts — NOT 1, which is the mistake this + // test guards against reintroducing). + expect(shouldRetryWidgetFetch(0, abortError)).toBe(true); + }); + + it("does not retry a second time once the retry itself has also failed", () => { + expect(shouldRetryWidgetFetch(1, abortError)).toBe(false); + }); + + it("retries once on a 502/503 from the backend, same as the app's own global default", () => { + const badGateway = Object.assign(new Error("Bad Gateway"), { status: 502 }); + const serviceUnavailable = Object.assign(new Error("Service Unavailable"), { + response: { status: 503 }, + }); + expect(shouldRetryWidgetFetch(0, badGateway)).toBe(true); + expect(shouldRetryWidgetFetch(0, serviceUnavailable)).toBe(true); + expect(shouldRetryWidgetFetch(1, badGateway)).toBe(false); + }); + + it("does not retry an ordinary failure (e.g. a 400/404/500), same as before this task's retry policy existed", () => { + const notFound = Object.assign(new Error("Not Found"), { status: 404 }); + const serverError = Object.assign(new Error("Internal Server Error"), { status: 500 }); + const genericFailure = new Error("Unsupported widget resourceType: bogus"); + expect(shouldRetryWidgetFetch(0, notFound)).toBe(false); + expect(shouldRetryWidgetFetch(0, serverError)).toBe(false); + expect(shouldRetryWidgetFetch(0, genericFailure)).toBe(false); + }); +}); diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/utils/widgetFetchConcurrency.ts b/apps/csm-portal/webapp/src/features/csm-dashboard/utils/widgetFetchConcurrency.ts new file mode 100644 index 0000000000..866e2c712a --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/utils/widgetFetchConcurrency.ts @@ -0,0 +1,227 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * How many widget data-fetch requests (`useWidgetData` / `useWidgetPieData`) + * may be in flight at once, across the whole app, not just one dashboard. + * + * A dashboard with N widgets previously fired N `/…/search` calls to + * `customer-entity-service` essentially simultaneously on mount (one per + * widget tile, each an independent `react-query` `queryFn`, no + * coordination between them) — `abt-engineer` alone has ~20 widgets, and a + * `shape: "pie"` widget fires one call per slice on top of that. Production + * logs showed bursts of 5-13 simultaneous `Request timeout: POST + * /cases/search` within ~170ms of each other on the entity-service, which + * the BFF surfaced as HTTP 503 ("upstream connect error or disconnect/reset + * before headers, reset reason: connection termination") at ~15s elapsed. + * + * `1` — fully sequential, one widget fetch in flight at a time. This + * supersedes an earlier `6` (chosen to match Chrome's own default + * per-origin HTTP/1.1 connection limit, roughly halving the worst observed + * prod burst while letting most dashboards render with barely perceptible + * delay): the user explicitly asked for strictly one-by-one loading on the + * abt-lead dashboard after checking `6` live, prioritizing backend-load + * reduction over dashboard render speed. `1` costs the most render-latency + * of any value here — a 20-widget dashboard now takes 20 sequential round + * trips instead of ~4 batches of 6 — which is the explicit trade the user + * chose, not an oversight. Tune here only; nothing else in the widget-fetch + * path should hardcode a concurrency number. + */ +export const WIDGET_FETCH_CONCURRENCY_LIMIT = 1; + +/** + * How long a single widget fetch may stay in flight before it's aborted and + * treated as failed. Matters a great deal more now that + * {@link WIDGET_FETCH_CONCURRENCY_LIMIT} is `1`: with no client-side + * timeout, a single request that never resolves (a dropped connection, a + * backend that hangs instead of erroring) would hold the app's one and + * only fetch slot forever, freezing every OTHER widget on every OTHER + * dashboard for the rest of that browser tab's session — this is the gap + * flagged as a follow-up risk when the concurrency cap first dropped to 1. + * + * `10_000` (10s) — a single, findable constant — matching how + * `WIDGET_FETCH_CONCURRENCY_LIMIT` is already tuned — rather than a + * per-call override: nothing about a widget's own config (resource type, + * shape, dashboard) plausibly needs a different timeout than any other + * widget, so a per-call parameter would be configurability nobody asked + * for. Tune here only (or via `__setWidgetFetchTimeoutMsForTests` below, + * test-only). Was `30_000` initially; dropped to `10_000` per direct + * instruction, no further reasoning given beyond wanting a hung widget to + * give up (and free its slot) sooner. + * + * `let`, not `const` — mutable ONLY through the test-only setter below. + * Everything that reads this (this module's own `withWidgetFetchSlot`, + * plus every doc comment) sees the live value via the module binding, so + * a test can shrink it to a few milliseconds and use real timers/`waitFor` + * instead of fake ones — see that function's own comment for why fake + * timers specifically don't work for this. + */ +export let WIDGET_FETCH_TIMEOUT_MS = 10_000; + +let activeCount = 0; +const waiters: Array<() => void> = []; + +function acquireWidgetFetchSlot(): Promise { + if (activeCount < WIDGET_FETCH_CONCURRENCY_LIMIT) { + activeCount += 1; + return Promise.resolve(); + } + return new Promise((resolve) => { + waiters.push(() => { + activeCount += 1; + resolve(); + }); + }); +} + +function releaseWidgetFetchSlot(): void { + activeCount = Math.max(0, activeCount - 1); + const next = waiters.shift(); + if (next) next(); +} + +/** + * Runs `fn` once a widget-fetch slot is free, releasing the slot as soon as + * `fn` settles (success, failure, OR timeout) so the next queued fetch can + * start. Callers past the cap simply await longer before `fn` starts — no + * error, no change to `fn`'s own result or to `react-query`'s loading/error + * state, which is exactly what a queued-but-not-yet-fired widget should + * show: its normal loading skeleton, same as an in-flight one. + * + * `fn` receives an `AbortSignal` — pass it straight through to `api.post`'s + * own `signal` option — that fires automatically after + * {@link WIDGET_FETCH_TIMEOUT_MS} of `fn` actually running (the timer + * starts once a slot is acquired, not while still queued behind another + * widget — queueing time is governed by the concurrency cap, not this + * timeout). An aborted fetch rejects like any other failed fetch: the + * widget's own `queryFn` throws, `react-query` marks it `isError`, and — + * the part that matters most here — this function's own `finally` still + * runs and releases the slot, so a hung request degrades to "this one + * widget shows an error" instead of "every widget behind it in the queue + * never loads." + * + * A hand-rolled FIFO semaphore rather than a dependency (`p-limit` etc.) — + * neither is already in `package.json`, and the mechanism this needs is a + * dozen lines. + */ +export async function withWidgetFetchSlot( + fn: (signal: AbortSignal) => Promise, +): Promise { + await acquireWidgetFetchSlot(); + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + controller.abort(); + }, WIDGET_FETCH_TIMEOUT_MS); + try { + return await fn(controller.signal); + } finally { + clearTimeout(timeoutId); + releaseWidgetFetchSlot(); + } +} + +/** + * `react-query` `retry` predicate for `useWidgetData`/`useWidgetPieData`'s + * own queries — set explicitly per-query (not left to inherit + * `AppWithConfig.tsx`'s app-wide `shouldRetryQuery`) so a widget query's + * retry policy is scoped to widget fetches only, not a silent app-wide + * behavior change smuggled in through this task. + * + * Retries exactly once (same "one retry, then stop" shape as the app's own + * global default for a 502/503) when the failure was THIS module's own + * timeout abort — `error.name === "AbortError"` is a reliable enough + * signal here specifically because nothing else in the widget-fetch path + * ever calls `AbortController.abort()`; `withWidgetFetchSlot` is the only + * source. A timed-out widget is deliberately NOT treated as a terminal + * failure on its first attempt — see {@link WIDGET_FETCH_TIMEOUT_MS}'s own + * intent: a slow widget should get out of everyone else's way (already + * true from the timeout + slot release alone) AND get a second chance + * once the rest of the queue has had a turn, rather than sitting in a + * permanent error state for the rest of the dashboard's life. + * + * That "after the rest of the queue" ordering needs no separate mechanism: + * every OTHER widget on the same dashboard already called + * `withWidgetFetchSlot` (and so entered the FIFO `waiters` queue below) at + * mount time, well before this one's timeout could possibly have fired — + * so by the time `react-query` re-invokes this queryFn for the retry, its + * fresh call to `acquireWidgetFetchSlot()` necessarily lands behind + * whichever of those widgets are still waiting. A retry re-enters the + * exact same queue as everyone else; it gets no special priority. + * + * Also retries once on a 502/503 from the backend itself — deliberately + * NOT just inheriting the app's own global default (see `shouldRetryQuery` + * in `AppWithConfig.tsx`), since setting ANY explicit `retry` option on a + * query replaces the whole option for that query rather than adding to + * it — a widget query needs its own predicate that does everything the + * global one does, plus the timeout case. + * + * `failureCount` here is 0 on the FIRST failure, not 1 — react-query's own + * retryer calls `retry(failureCount, error)` BEFORE incrementing its + * internal counter (verified against `@tanstack/query-core`'s own + * `retryer.ts` directly rather than assumed), so `failureCount >= 1` is + * what caps this at exactly one retry (two attempts total): first failure + * sees `0` (retry allowed), second sees `1` (stop). An off-by-one here + * (`>= 2`, matching the global default's own threshold — copied from it + * without re-deriving this) silently allowed a 2nd retry — caught in this + * task's own verification, not by inspection alone. + */ +export function shouldRetryWidgetFetch(failureCount: number, error: Error): boolean { + if (failureCount >= 1) return false; + if (error?.name === "AbortError") return true; + const errorWithStatus = error as Error & { + response?: { status?: number }; + status?: number; + }; + const statusCode = errorWithStatus.response?.status || errorWithStatus.status; + return statusCode === 502 || statusCode === 503; +} + +/** + * Test-only escape hatch: clears every held/queued slot. `activeCount` and + * `waiters` are module-level (deliberately — the cap is app-wide, not + * per-dashboard), which means a test that mounts a widget whose fetch is + * left permanently pending (`postMock.mockReturnValue(new Promise(() => {}))`, + * a real pattern this codebase uses to assert a loading state) never + * releases the slot it acquired — harmless at a cap of 6 (five slots still + * free for the rest of that test file), but at a cap of 1 it permanently + * starves every OTHER test in the same file, since there is nothing left + * to acquire. Call this in a `beforeEach`/`afterEach` in any test file that + * exercises a widget whose fetch may be left unresolved. + */ +export function __resetWidgetFetchConcurrencyForTests(): void { + activeCount = 0; + waiters.length = 0; +} + +/** + * Test-only escape hatch: overrides {@link WIDGET_FETCH_TIMEOUT_MS} for the + * rest of the current test file/run. A real integration test that needs to + * prove "aborts at ~the configured timeout, releases the slot, retries + * once" through actual `react-query` + React rendering cannot reliably use + * `vi.useFakeTimers()` for that proof — `@tanstack/query-core`'s internal + * notification scheduling (`notifyManager`, `systemSetTimeoutZero`) and + * React's own scheduler did not observably settle under + * `vi.advanceTimersByTimeAsync` in this app's actual dependency versions + * when verified directly (a bare two-hook `useQuery` test, no custom code + * at all, stayed `pending` forever after generous fake-timer advancement + + * flushing) — so real timers + a shrunk-down real timeout is the reliable + * path, not a workaround for a mistake in this module's own code. Always + * pair a call to this with one restoring the real default afterward (see + * any test file that uses it). + */ +export function __setWidgetFetchTimeoutMsForTests(ms: number): void { + WIDGET_FETCH_TIMEOUT_MS = ms; +} diff --git a/apps/csm-portal/webapp/src/hooks/useElementVisibleOnce.test.ts b/apps/csm-portal/webapp/src/hooks/useElementVisibleOnce.test.ts new file mode 100644 index 0000000000..b211d3e5d0 --- /dev/null +++ b/apps/csm-portal/webapp/src/hooks/useElementVisibleOnce.test.ts @@ -0,0 +1,184 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createRef } from "react"; +import { useElementVisibleOnce } from "@hooks/useElementVisibleOnce"; + +/** jsdom doesn't implement `IntersectionObserver` at all (no precedent for + * mocking it elsewhere in this codebase — checked). A minimal controllable + * stub: each instance records its own callback/options and exposes a way + * for a test to fire an intersection event manually, standing in for the + * browser actually scrolling the element into view. */ +class FakeIntersectionObserver { + static instances: FakeIntersectionObserver[] = []; + observedNodes: Element[] = []; + disconnectCalls = 0; + + constructor( + public callback: IntersectionObserverCallback, + public options?: IntersectionObserverInit, + ) { + FakeIntersectionObserver.instances.push(this); + } + + observe(node: Element): void { + this.observedNodes.push(node); + } + + unobserve(node: Element): void { + this.observedNodes = this.observedNodes.filter((n) => n !== node); + } + + disconnect(): void { + this.disconnectCalls += 1; + this.observedNodes = []; + } + + /** Simulates the browser reporting `node` as intersecting (or not). */ + triggerIntersection(node: Element, isIntersecting: boolean): void { + this.callback( + [{ isIntersecting, target: node } as IntersectionObserverEntry], + this as unknown as IntersectionObserver, + ); + } +} + +describe("useElementVisibleOnce", () => { + const originalIntersectionObserver = globalThis.IntersectionObserver; + + beforeEach(() => { + FakeIntersectionObserver.instances = []; + (globalThis as { IntersectionObserver?: unknown }).IntersectionObserver = + FakeIntersectionObserver; + }); + + afterEach(() => { + (globalThis as { IntersectionObserver?: unknown }).IntersectionObserver = + originalIntersectionObserver; + }); + + it("starts false and stays false until the observed element actually intersects", () => { + const ref = createRef(); + // @ts-expect-error -- a bare object stands in for the DOM node; the + // hook only ever passes it back to observe()/the callback, never reads + // properties off it itself. + ref.current = { tagName: "DIV" }; + + const { result } = renderHook(() => useElementVisibleOnce(ref)); + + expect(result.current).toBe(false); + expect(FakeIntersectionObserver.instances).toHaveLength(1); + expect(FakeIntersectionObserver.instances[0].observedNodes).toEqual([ref.current]); + }); + + it("passes the given rootMargin/threshold through to the observer (default 0px / 0.25)", () => { + const ref = createRef(); + // @ts-expect-error -- see above. + ref.current = { tagName: "DIV" }; + + renderHook(() => useElementVisibleOnce(ref)); + + // No pre-fetch-ahead-of-scroll margin, and at least a quarter of the + // tile's own area must be on screen — see the constants' own doc + // comments for why (an edge sliver, or a widget still below the fold + // that a generous margin used to count as "visible", must not fire a + // fetch). + expect(FakeIntersectionObserver.instances[0].options).toEqual({ + rootMargin: "0px", + threshold: 0.25, + }); + }); + + it("honors a caller-supplied rootMargin and threshold", () => { + const ref = createRef(); + // @ts-expect-error -- see above. + ref.current = { tagName: "DIV" }; + + renderHook(() => useElementVisibleOnce(ref, "50px", 0.5)); + + expect(FakeIntersectionObserver.instances[0].options).toEqual({ + rootMargin: "50px", + threshold: 0.5, + }); + }); + + it("flips to true and disconnects once the element intersects, and never re-observes after", () => { + const ref = createRef(); + // @ts-expect-error -- see above. + ref.current = { tagName: "DIV" }; + + const { result, rerender } = renderHook(() => useElementVisibleOnce(ref)); + const observer = FakeIntersectionObserver.instances[0]; + + expect(result.current).toBe(false); + + act(() => { + observer.triggerIntersection(ref.current as unknown as Element, true); + }); + rerender(); + + expect(result.current).toBe(true); + expect(observer.disconnectCalls).toBe(1); + + // A later re-render must not spin up a second observer on an + // already-latched element. + rerender(); + expect(FakeIntersectionObserver.instances).toHaveLength(1); + }); + + it("ignores a non-intersecting entry and keeps waiting", () => { + const ref = createRef(); + // @ts-expect-error -- see above. + ref.current = { tagName: "DIV" }; + + const { result, rerender } = renderHook(() => useElementVisibleOnce(ref)); + const observer = FakeIntersectionObserver.instances[0]; + + act(() => { + observer.triggerIntersection(ref.current as unknown as Element, false); + }); + rerender(); + + expect(result.current).toBe(false); + expect(observer.disconnectCalls).toBe(0); + }); + + it("falls back to always-visible (true) when IntersectionObserver doesn't exist in this runtime", () => { + (globalThis as { IntersectionObserver?: unknown }).IntersectionObserver = undefined; + const ref = createRef(); + // @ts-expect-error -- see above. + ref.current = { tagName: "DIV" }; + + const { result } = renderHook(() => useElementVisibleOnce(ref)); + + expect(result.current).toBe(true); + expect(FakeIntersectionObserver.instances).toHaveLength(0); + }); + + it("disconnects the observer on unmount even if the element never intersected", () => { + const ref = createRef(); + // @ts-expect-error -- see above. + ref.current = { tagName: "DIV" }; + + const { unmount } = renderHook(() => useElementVisibleOnce(ref)); + const observer = FakeIntersectionObserver.instances[0]; + + unmount(); + + expect(observer.disconnectCalls).toBe(1); + }); +}); diff --git a/apps/csm-portal/webapp/src/hooks/useElementVisibleOnce.ts b/apps/csm-portal/webapp/src/hooks/useElementVisibleOnce.ts new file mode 100644 index 0000000000..efa95e35c2 --- /dev/null +++ b/apps/csm-portal/webapp/src/hooks/useElementVisibleOnce.ts @@ -0,0 +1,110 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { useEffect, useState, type RefObject } from "react"; + +/** + * How far ahead of the viewport an element is treated as visible. + * + * Was `"200px"` — deliberately `"0px"` now. On the real abt-engineer + * dashboard that 200px pre-fetch buffer, combined with `threshold: 0` + * (the old implicit default — any nonzero overlap counts), meant a widget + * row not yet meaningfully on screen still got counted as "visible" the + * instant the dashboard mounted: with only 1-2 widgets actually on screen, + * 4 fired their fetch. `"0px"` means only the real viewport counts — no + * pre-fetch-ahead-of-scroll behavior anymore. That trades away the + * "already loaded by the time you scroll to it" softness this hook + * originally aimed for, but the user explicitly prioritized not + * over-fetching over that convenience. + */ +const DEFAULT_ROOT_MARGIN = "0px"; + +/** + * How much of the element's own area must overlap the (margin-adjusted) + * root before it counts as visible. `0` (the implicit browser default) + * means a single overlapping pixel at the viewport's edge — e.g. a grid + * row that's 90% below the fold, with just its very top sliver crossing + * into view — already counts as "visible". `0.25` requires at least a + * quarter of the tile's own area on screen before its fetch fires, so an + * edge sliver doesn't trigger it. + */ +const DEFAULT_THRESHOLD = 0.25; + +/** + * Tracks whether the given element has ever intersected the viewport (or + * come within `rootMargin` of it, by at least `threshold` of its own + * area), and keeps reporting `true` forever once it has — this is a + * one-shot "has this been seen" latch, not a live on-screen/off-screen + * toggle. Built for gating a widget tile's own data fetch (see + * `DashboardWidgetTile`): once a tile has loaded its data, it must not + * unmount/refetch just because the user scrolled it back out of view, so + * the observer disconnects itself the first time it fires rather than + * continuing to track subsequent intersections. + * + * Falls back to `true` (i.e. "always visible") when `IntersectionObserver` + * doesn't exist in this runtime, rather than never firing — a widget in a + * browser/environment without it should still load, just without the lazy + * gating, which is strictly better than a widget stuck loading forever. The + * same fallback also means test environments that don't stub + * `IntersectionObserver` (jsdom doesn't implement it) see every widget as + * immediately visible, matching this app's pre-lazy-load test expectations + * without needing every existing test to add a mock. + */ +export function useElementVisibleOnce( + ref: RefObject, + rootMargin: string = DEFAULT_ROOT_MARGIN, + threshold: number = DEFAULT_THRESHOLD, +): boolean { + const [hasBeenVisible, setHasBeenVisible] = useState( + () => typeof IntersectionObserver === "undefined", + ); + + useEffect(() => { + if (hasBeenVisible) { + // Either already latched true, or IntersectionObserver doesn't exist + // in this runtime (see the initializer above) — nothing to observe. + return; + } + const node = ref.current; + if (!node) { + return; + } + + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + setHasBeenVisible(true); + observer.disconnect(); + } + }, + { rootMargin, threshold }, + ); + observer.observe(node); + + return () => { + observer.disconnect(); + }; + // `hasBeenVisible`/`rootMargin`/`threshold` deliberately excluded: + // re-running on `hasBeenVisible`'s own change would just re-observe an + // already-latched node for no reason, and `rootMargin`/`threshold` are + // effectively constants per call site. `ref` itself is a stable + // RefObject — this effect keys on `ref.current` (the actual DOM node) + // instead, so it re-observes if the tile's ref gets attached to a new + // node. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ref.current]); + + return hasBeenVisible; +}