This is an automated email from the ASF dual-hosted git repository.

pierrejeambrun pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 2033e1a57c7 Make Dag pause toggle update immediately on click (#69134)
2033e1a57c7 is described below

commit 2033e1a57c7fe00fbf4c3f36021c0612e71efd2d
Author: Pierre Jeambrun <[email protected]>
AuthorDate: Thu Jul 2 11:38:26 2026 +0200

    Make Dag pause toggle update immediately on click (#69134)
    
    * Make Dag pause toggle update immediately on click
    
    The pause/unpause switch on the Dags list page waited for the dags list
    refetch to settle before flipping, which under fast interaction looked
    like the first clicks were dropped: clicking once on several rows left
    them all visually unchanged until enough subsequent clicks accumulated
    and the list eventually refetched.
    
    Apply React Query's optimistic update pattern: write the new is_paused
    into the dag detail and dags list caches on mutate, roll back if the
    server rejects, and invalidate the list on settle so filtered views
    (e.g. paused=true) drop the row that no longer matches the filter.
    
    * Test the optimistic pause toggle against a sibling filtered Dag list
    
    The test seeded a single dags-list query and "verified" sibling invalidation
    with isFetching(...) >= 0, which is always true and asserted nothing. Seed a
    second list under a different filter and assert that both the prefix-keyed
    optimistic write and the on-settle invalidation reach it, so the test proves
    the cache update is not limited to the one seeded list variant.
---
 .../airflow/ui/src/queries/useTogglePause.test.tsx | 183 +++++++++++++++++++++
 .../src/airflow/ui/src/queries/useTogglePause.ts   | 104 ++++++++++--
 2 files changed, 276 insertions(+), 11 deletions(-)

diff --git a/airflow-core/src/airflow/ui/src/queries/useTogglePause.test.tsx 
b/airflow-core/src/airflow/ui/src/queries/useTogglePause.test.tsx
new file mode 100644
index 00000000000..47fa20849bf
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/queries/useTogglePause.test.tsx
@@ -0,0 +1,183 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF 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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { renderHook, waitFor } from "@testing-library/react";
+import { http, HttpResponse } from "msw";
+import { setupServer } from "msw/node";
+import React from "react";
+import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
+
+import { UseDagServiceGetDagsUiKeyFn } from "openapi/queries";
+import type { DAGWithLatestDagRunsCollectionResponse } from 
"openapi/requests/types.gen";
+import { useTogglePause } from "src/queries/useTogglePause";
+
+const DAG_ID = "dag_under_test";
+
+const buildDagsList = (isPaused: boolean): 
DAGWithLatestDagRunsCollectionResponse => ({
+  dags: [
+    {
+      asset_expression: null,
+      bundle_name: null,
+      bundle_version: null,
+      dag_display_name: DAG_ID,
+      dag_id: DAG_ID,
+      description: null,
+      file_token: "",
+      fileloc: "/dags/dag.py",
+      has_import_errors: false,
+      has_task_concurrency_limits: false,
+      is_favorite: false,
+      is_paused: isPaused,
+      is_stale: false,
+      last_expired: null,
+      last_parsed_time: null,
+      latest_dag_runs: [],
+      max_active_runs: 16,
+      max_active_tasks: 16,
+      max_consecutive_failed_dag_runs: 0,
+      next_dagrun_create_after: null,
+      next_dagrun_data_interval_end: null,
+      next_dagrun_data_interval_start: null,
+      next_dagrun_logical_date: null,
+      next_dagrun_run_after: null,
+      owners: ["airflow"],
+      pending_actions: 0,
+      tags: [],
+      timetable_description: null,
+      timetable_partitioned: false,
+      timetable_summary: null,
+    } as unknown as DAGWithLatestDagRunsCollectionResponse["dags"][number],
+  ],
+  total_entries: 1,
+});
+
+const server = setupServer();
+
+const createWrapper =
+  (queryClient: QueryClient) =>
+  ({ children }: { readonly children: React.ReactNode }) => (
+    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
+  );
+
+const seedClient = (initialIsPaused: boolean) => {
+  const queryClient = new QueryClient({
+    defaultOptions: {
+      mutations: { retry: false },
+      queries: { gcTime: Infinity, retry: false, staleTime: Infinity },
+    },
+  });
+  const dagsListKey = UseDagServiceGetDagsUiKeyFn({ dagRunsLimit: 1 });
+  // A sibling list query with a different filter (paused=false), so tests can 
verify the
+  // prefix-keyed optimistic write and invalidation reach every dags-list 
cache, not just one.
+  const filteredListKey = UseDagServiceGetDagsUiKeyFn({ dagRunsLimit: 1, 
paused: false });
+
+  queryClient.setQueryData<DAGWithLatestDagRunsCollectionResponse>(
+    dagsListKey,
+    buildDagsList(initialIsPaused),
+  );
+  queryClient.setQueryData<DAGWithLatestDagRunsCollectionResponse>(
+    filteredListKey,
+    buildDagsList(initialIsPaused),
+  );
+
+  return { dagsListKey, filteredListKey, queryClient };
+};
+
+beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
+afterEach(() => server.resetHandlers());
+afterAll(() => server.close());
+
+describe("useTogglePause", () => {
+  it("optimistically flips is_paused in the dags list cache as soon as 
mutate() is called", async () => {
+    server.use(
+      http.patch("*/api/v2/dags/:dagId", async () => {
+        // Make the server slow so the test can observe the optimistic state
+        // before the response is settled.
+        await new Promise((resolve) => {
+          setTimeout(resolve, 50);
+        });
+
+        return HttpResponse.json({ dag_id: DAG_ID, is_paused: true });
+      }),
+    );
+
+    const { dagsListKey, filteredListKey, queryClient } = seedClient(false);
+    const { result } = renderHook(() => useTogglePause({ dagId: DAG_ID }), {
+      wrapper: createWrapper(queryClient),
+    });
+
+    result.current.mutate({ dagId: DAG_ID, requestBody: { is_paused: true } });
+
+    // The Switch is bound to is_paused — the optimistic update must apply 
before the network
+    // response settles so the user sees the flip on click. It is keyed by the 
list prefix, so
+    // every dags-list cache flips, including a sibling query with a different 
filter.
+    await waitFor(() => {
+      expect(
+        
queryClient.getQueryData<DAGWithLatestDagRunsCollectionResponse>(dagsListKey)?.dags[0]?.is_paused,
+      ).toBe(true);
+      expect(
+        
queryClient.getQueryData<DAGWithLatestDagRunsCollectionResponse>(filteredListKey)?.dags[0]?.is_paused,
+      ).toBe(true);
+    });
+
+    await waitFor(() => expect(result.current.isPending).toBe(false));
+    // Post-settle the cache still reflects the new value.
+    const settled = 
queryClient.getQueryData<DAGWithLatestDagRunsCollectionResponse>(dagsListKey);
+
+    expect(settled?.dags[0]?.is_paused).toBe(true);
+  });
+
+  it("rolls back the optimistic update when the server rejects the change", 
async () => {
+    server.use(http.patch("*/api/v2/dags/:dagId", () => new HttpResponse(null, 
{ status: 500 })));
+
+    const { dagsListKey, queryClient } = seedClient(false);
+    const { result } = renderHook(() => useTogglePause({ dagId: DAG_ID }), {
+      wrapper: createWrapper(queryClient),
+    });
+
+    result.current.mutate({ dagId: DAG_ID, requestBody: { is_paused: true } });
+
+    await waitFor(() => expect(result.current.isError).toBe(true));
+
+    const cached = 
queryClient.getQueryData<DAGWithLatestDagRunsCollectionResponse>(dagsListKey);
+
+    expect(cached?.dags[0]?.is_paused).toBe(false);
+  });
+
+  it("invalidates the dags list query on settle so filtered lists refetch", 
async () => {
+    server.use(
+      http.patch("*/api/v2/dags/:dagId", () => HttpResponse.json({ dag_id: 
DAG_ID, is_paused: true })),
+    );
+
+    const { dagsListKey, filteredListKey, queryClient } = seedClient(false);
+    const { result } = renderHook(() => useTogglePause({ dagId: DAG_ID }), {
+      wrapper: createWrapper(queryClient),
+    });
+
+    result.current.mutate({ dagId: DAG_ID, requestBody: { is_paused: true } });
+
+    await waitFor(() => expect(result.current.isPending).toBe(false));
+
+    // Both the seeded list and a sibling list with a different filter 
(paused=false) are marked
+    // stale, because invalidation is keyed by the shared prefix — so a 
filtered consumer refetches
+    // and may drop the dag from the visible page.
+    expect(queryClient.getQueryState(dagsListKey)?.isInvalidated).toBe(true);
+    
expect(queryClient.getQueryState(filteredListKey)?.isInvalidated).toBe(true);
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/queries/useTogglePause.ts 
b/airflow-core/src/airflow/ui/src/queries/useTogglePause.ts
index 04a7c510acb..9ed05420f2c 100644
--- a/airflow-core/src/airflow/ui/src/queries/useTogglePause.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useTogglePause.ts
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { useQueryClient } from "@tanstack/react-query";
+import { useQueryClient, type QueryKey } from "@tanstack/react-query";
 import { useTranslation } from "react-i18next";
 
 import {
@@ -27,17 +27,102 @@ import {
   useDagServiceGetDagsUiKey,
   UseTaskInstanceServiceGetTaskInstancesKeyFn,
 } from "openapi/queries";
+import type {
+  DAGDetailsResponse,
+  DAGPatchBody,
+  DAGResponse,
+  DAGWithLatestDagRunsCollectionResponse,
+} from "openapi/requests/types.gen";
 import { createErrorToaster } from "src/utils";
 
+type TogglePauseVariables = { dagId: string; requestBody: DAGPatchBody };
+
+type TogglePauseContext = {
+  previousDag: DAGResponse | undefined;
+  previousDagDetails: DAGDetailsResponse | undefined;
+  previousDagsLists: Array<[QueryKey, DAGWithLatestDagRunsCollectionResponse | 
undefined]>;
+};
+
 export const useTogglePause = ({ dagId }: { dagId: string }) => {
   const queryClient = useQueryClient();
   const { t: translate } = useTranslation("common");
 
-  const onSuccess = async () => {
-    const queryKeys = [
-      [useDagServiceGetDagsUiKey],
-      UseDagServiceGetDagKeyFn({ dagId }, [{ dagId }]),
-      UseDagServiceGetDagDetailsKeyFn({ dagId }, [{ dagId }]),
+  const dagKey = UseDagServiceGetDagKeyFn({ dagId }, [{ dagId }]);
+  const dagDetailsKey = UseDagServiceGetDagDetailsKeyFn({ dagId }, [{ dagId 
}]);
+  const dagsListPrefix: QueryKey = [useDagServiceGetDagsUiKey];
+
+  const onMutate = async ({ requestBody }: TogglePauseVariables): 
Promise<TogglePauseContext> => {
+    const nextIsPaused = requestBody.is_paused;
+
+    // Cancel in-flight refetches so they cannot overwrite the optimistic 
update.
+    await Promise.all([
+      queryClient.cancelQueries({ queryKey: dagKey }),
+      queryClient.cancelQueries({ queryKey: dagDetailsKey }),
+      queryClient.cancelQueries({ queryKey: dagsListPrefix }),
+    ]);
+
+    const previousDag = queryClient.getQueryData<DAGResponse>(dagKey);
+    const previousDagDetails = 
queryClient.getQueryData<DAGDetailsResponse>(dagDetailsKey);
+    const previousDagsLists = 
queryClient.getQueriesData<DAGWithLatestDagRunsCollectionResponse>({
+      queryKey: dagsListPrefix,
+    });
+
+    // Optimistically reflect the new is_paused value so the Switch flips
+    // immediately on click rather than waiting for the server round-trip.
+    if (previousDag !== undefined) {
+      queryClient.setQueryData<DAGResponse>(dagKey, { ...previousDag, 
is_paused: nextIsPaused });
+    }
+    if (previousDagDetails !== undefined) {
+      queryClient.setQueryData<DAGDetailsResponse>(dagDetailsKey, {
+        ...previousDagDetails,
+        is_paused: nextIsPaused,
+      });
+    }
+    queryClient.setQueriesData<DAGWithLatestDagRunsCollectionResponse>(
+      { queryKey: dagsListPrefix },
+      (current) =>
+        current === undefined
+          ? current
+          : {
+              ...current,
+              dags: current.dags.map((dag) =>
+                dag.dag_id === dagId ? { ...dag, is_paused: nextIsPaused } : 
dag,
+              ),
+            },
+    );
+
+    return { previousDag, previousDagDetails, previousDagsLists };
+  };
+
+  const onError = (
+    error: unknown,
+    _variables: TogglePauseVariables,
+    context: TogglePauseContext | undefined,
+  ) => {
+    // Roll back the optimistic update if the server rejected the change.
+    if (context !== undefined) {
+      if (context.previousDag !== undefined) {
+        queryClient.setQueryData(dagKey, context.previousDag);
+      }
+      if (context.previousDagDetails !== undefined) {
+        queryClient.setQueryData(dagDetailsKey, context.previousDagDetails);
+      }
+      context.previousDagsLists.forEach(([key, data]) => {
+        queryClient.setQueryData(key, data);
+      });
+    }
+
+    createErrorToaster(error, { titleKey: "common:error.title" }, translate);
+  };
+
+  const onSettled = async () => {
+    // Invalidate after the mutation settles (success or error) so filtered 
list
+    // queries (e.g. paused=true/false) refetch and may move the dag in or out
+    // of the visible page.
+    const queryKeys: Array<QueryKey> = [
+      dagsListPrefix,
+      dagKey,
+      dagDetailsKey,
       UseDagRunServiceGetDagRunsKeyFn({ dagId }, [{ dagId }]),
       UseTaskInstanceServiceGetTaskInstancesKeyFn({ dagId, dagRunId: "~" }, [{ 
dagId, dagRunId: "~" }]),
     ];
@@ -45,12 +130,9 @@ export const useTogglePause = ({ dagId }: { dagId: string 
}) => {
     await Promise.all(queryKeys.map((key) => queryClient.invalidateQueries({ 
queryKey: key })));
   };
 
-  const onError = (error: unknown) => {
-    createErrorToaster(error, { titleKey: "common:error.title" }, translate);
-  };
-
   return useDagServicePatchDag({
     onError,
-    onSuccess,
+    onMutate,
+    onSettled,
   });
 };

Reply via email to