From df3b64de11f46bd7adb3b13a442d82ddefaf347b Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Tue, 1 Sep 2026 00:16:52 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Fire=20a=20single=20audit=20quer?= =?UTF-8?q?y=20per=20query-control=20change?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single control change on the All Messages view could fire the same query up to three times: the controls watcher called the fetch after pushing the new query to the router, the route watcher fetched again in response to that push, and the controls watcher also watched the route so it re-triggered itself once more. On slow audit instances the duplicates pile up server-side and were observed running concurrently for minutes in customer RavenDB debug packages. The route is now the single source of truth: control changes only push to the router, and only a route change triggers the fetch. --- .../src/components/audit/AuditList.spec.ts | 45 ++++++++++++++++++- .../src/components/audit/AuditList.vue | 12 ++--- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/src/Frontend/src/components/audit/AuditList.spec.ts b/src/Frontend/src/components/audit/AuditList.spec.ts index 6a1d98384..622a6ca16 100644 --- a/src/Frontend/src/components/audit/AuditList.spec.ts +++ b/src/Frontend/src/components/audit/AuditList.spec.ts @@ -5,6 +5,7 @@ import { createRouter, createMemoryHistory } from "vue-router"; import { ref, shallowReadonly, nextTick, type Ref } from "vue"; import { flushPromises } from "@vue/test-utils"; import AuditList from "@/components/audit/AuditList.vue"; +import { useAuditStore } from "@/stores/AuditStore"; import { type default as Message, MessageStatus } from "@/resources/Message"; /** @@ -47,6 +48,8 @@ interface QueryStateAssertions { interface RenderResult { verify: QueryStateAssertions; isRefreshing: Ref; + refreshNow: ReturnType; + store: ReturnType; } // ==================== DOM Query Helpers ==================== @@ -104,9 +107,10 @@ function createMessage(id = "msg-1"): Message { async function renderAuditList(messages: Message[] = []): Promise { const isRefreshing = ref(false); + const refreshNow = vi.fn().mockResolvedValue(undefined); vi.mocked(useFetchWithAutoRefresh).mockReturnValue({ - refreshNow: vi.fn().mockResolvedValue(undefined), + refreshNow, isRefreshing: shallowReadonly(isRefreshing), updateInterval: vi.fn(), isActive: ref(false), @@ -176,7 +180,18 @@ async function renderAuditList(messages: Message[] = []): Promise }, }; - return { verify, isRefreshing }; + return { verify, isRefreshing, refreshNow, store: useAuditStore(pinia) }; +} + +// A control change reaches the fetch via two async hops (controls watcher -> router.push -> route watcher), +// so settle the microtask queue a few times before counting queries. +async function waitForRouteDrivenQuery() { + await flushPromises(); + await nextTick(); + await flushPromises(); + await nextTick(); + await flushPromises(); + await nextTick(); } async function waitForFirstLoadToComplete() { @@ -298,4 +313,30 @@ describe("FEATURE: Audit Messages Query State", () => { verify.overlayIsNotVisible(); }); }); + + describe("RULE: A query-control change results in exactly one query", () => { + test("EXAMPLE: Changing the filter text fires a single query", async () => { + const { refreshNow, store } = await renderAuditList([createMessage()]); + + await waitForFirstLoadToComplete(); + const queriesAfterFirstLoad = refreshNow.mock.calls.length; + + store.messageFilterString = "orders"; + await waitForRouteDrivenQuery(); + + expect(refreshNow.mock.calls.length - queriesAfterFirstLoad).toBe(1); + }); + + test("EXAMPLE: Changing the endpoint fires a single query", async () => { + const { refreshNow, store } = await renderAuditList([createMessage()]); + + await waitForFirstLoadToComplete(); + const queriesAfterFirstLoad = refreshNow.mock.calls.length; + + store.selectedEndpointName = "Sales.Endpoint"; + await waitForRouteDrivenQuery(); + + expect(refreshNow.mock.calls.length - queriesAfterFirstLoad).toBe(1); + }); + }); }); diff --git a/src/Frontend/src/components/audit/AuditList.vue b/src/Frontend/src/components/audit/AuditList.vue index 134c2e48b..19dbd77a7 100644 --- a/src/Frontend/src/components/audit/AuditList.vue +++ b/src/Frontend/src/components/audit/AuditList.vue @@ -72,16 +72,18 @@ onBeforeMount(() => { }, 0); }); +// The route is the single source of truth for the query: control changes only push to the router, +// and only a route change triggers a fetch. Having the fetch in both watchers (and the route in the +// controls watcher) made a single control change fire the same query up to three times. watch( - () => router.currentRoute.value.query, + () => route.query, async () => { setQuery(); await refreshNow(); - }, - { deep: true } + } ); -const watchHandle = watch([() => route.query, itemsPerPage, sortBy, messageFilterString, selectedEndpointName, dateRange], async () => { +const watchHandle = watch([itemsPerPage, sortBy, messageFilterString, selectedEndpointName, dateRange], async () => { if (firstLoad.value) { return; } @@ -101,8 +103,6 @@ const watchHandle = watch([() => route.query, itemsPerPage, sortBy, messageFilte pageSize: itemsPerPage.value, }, }); - - await refreshNow(); }); function setQuery() {