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

bbovenzi 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 a14105b2592 UI: Refresh task details immediately when switching tasks 
(#70789)
a14105b2592 is described below

commit a14105b25926246a8d61ecb494c880b477e6f862
Author: Andrew Chang <[email protected]>
AuthorDate: Tue Aug 4 03:14:55 2026 +0800

    UI: Refresh task details immediately when switching tasks (#70789)
    
    Cached task data can remain fresh for five minutes after navigating away, 
leaving the selected task out of sync with Graph view until the next polling 
interval.
---
 .../ui/src/components/TaskTrySelect.test.tsx       | 128 ++++++++++++++++++
 .../airflow/ui/src/components/TaskTrySelect.tsx    |   1 +
 .../src/pages/TaskInstance/TaskInstance.test.tsx   | 144 +++++++++++++++++++++
 .../ui/src/pages/TaskInstance/TaskInstance.tsx     |   1 +
 4 files changed, 274 insertions(+)

diff --git a/airflow-core/src/airflow/ui/src/components/TaskTrySelect.test.tsx 
b/airflow-core/src/airflow/ui/src/components/TaskTrySelect.test.tsx
new file mode 100644
index 00000000000..9a1e39766b1
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/TaskTrySelect.test.tsx
@@ -0,0 +1,128 @@
+/*!
+ * 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 { ChakraProvider, defaultSystem } from "@chakra-ui/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { render, screen } from "@testing-library/react";
+import type { PropsWithChildren } from "react";
+import { MemoryRouter } from "react-router-dom";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { UseTaskInstanceServiceGetMappedTaskInstanceTriesKeyFn } from 
"openapi/queries";
+import {
+  TaskInstanceService,
+  type TaskInstanceHistoryCollectionResponse,
+  type TaskInstanceHistoryResponse,
+  type TaskInstanceResponse,
+} from "openapi/requests";
+
+import { TaskTrySelect } from "./TaskTrySelect";
+
+vi.mock("src/utils", async () => {
+  const actual = await vi.importActual("src/utils");
+
+  return {
+    ...actual,
+    useAutoRefresh: vi.fn(() => false),
+  };
+});
+
+const DAG_ID = "test_dag";
+const DAG_RUN_ID = "test_run";
+const TASK_A = "task_a";
+const TASK_B = "task_b";
+
+const buildTaskInstance = (taskId: string, tryNumber: number): 
TaskInstanceResponse =>
+  ({
+    dag_id: DAG_ID,
+    dag_run_id: DAG_RUN_ID,
+    id: `${taskId}-id`,
+    map_index: -1,
+    state: "success",
+    task_display_name: taskId,
+    task_id: taskId,
+    try_number: tryNumber,
+  }) as TaskInstanceResponse;
+
+const buildTaskTry = (tryNumber: number): TaskInstanceHistoryResponse =>
+  ({
+    dag_id: DAG_ID,
+    dag_run_id: DAG_RUN_ID,
+    map_index: -1,
+    state: "success",
+    task_display_name: TASK_A,
+    task_id: TASK_A,
+    try_number: tryNumber,
+  }) as TaskInstanceHistoryResponse;
+
+const buildTaskTries = (tryNumbers: Array<number>): 
TaskInstanceHistoryCollectionResponse => ({
+  task_instances: tryNumbers.map(buildTaskTry),
+  total_entries: tryNumbers.length,
+});
+
+const createWrapper =
+  (queryClient: QueryClient) =>
+  ({ children }: PropsWithChildren) => (
+    <ChakraProvider value={defaultSystem}>
+      <QueryClientProvider client={queryClient}>
+        <MemoryRouter>{children}</MemoryRouter>
+      </QueryClientProvider>
+    </ChakraProvider>
+  );
+
+afterEach(() => vi.restoreAllMocks());
+
+describe("TaskTrySelect", () => {
+  it("refetches cached tries immediately when switching tasks", async () => {
+    const queryClient = new QueryClient({
+      defaultOptions: {
+        queries: {
+          retry: false,
+          staleTime: 5 * 60 * 1000,
+        },
+      },
+    });
+    const params = {
+      dagId: DAG_ID,
+      dagRunId: DAG_RUN_ID,
+      mapIndex: -1,
+      taskId: TASK_A,
+    };
+
+    queryClient.setQueryData(
+      UseTaskInstanceServiceGetMappedTaskInstanceTriesKeyFn(params),
+      buildTaskTries([1, 2]),
+    );
+    vi.spyOn(TaskInstanceService, 
"getMappedTaskInstanceTries").mockResolvedValue(buildTaskTries([1, 2, 3]));
+
+    const { rerender } = render(
+      <TaskTrySelect selectedTryNumber={1} 
taskInstance={buildTaskInstance(TASK_B, 1)} />,
+      { wrapper: createWrapper(queryClient) },
+    );
+
+    rerender(<TaskTrySelect selectedTryNumber={3} 
taskInstance={buildTaskInstance(TASK_A, 3)} />);
+
+    expect(await 
screen.findByTestId("log-attempt-select-button-3")).toBeTruthy();
+    expect(
+      screen
+        .getAllByTestId(/^log-attempt-select-button-/u)
+        .map((button) => button.getAttribute("data-testid")),
+    ).toEqual(["log-attempt-select-button-1", "log-attempt-select-button-2", 
"log-attempt-select-button-3"]);
+    
expect(TaskInstanceService.getMappedTaskInstanceTries).toHaveBeenCalledWith(params);
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/components/TaskTrySelect.tsx 
b/airflow-core/src/airflow/ui/src/components/TaskTrySelect.tsx
index 5115383820e..8cbf595d0c2 100644
--- a/airflow-core/src/airflow/ui/src/components/TaskTrySelect.tsx
+++ b/airflow-core/src/airflow/ui/src/components/TaskTrySelect.tsx
@@ -60,6 +60,7 @@ export const TaskTrySelect = ({ onSelectTryNumber, 
selectedTryNumber, taskInstan
         query.state.data?.task_instances.some((ti) => 
isStatePending(ti.state)) || isStatePending(state)
           ? refetchInterval
           : false,
+      staleTime: 0,
     },
   );
 
diff --git 
a/airflow-core/src/airflow/ui/src/pages/TaskInstance/TaskInstance.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/TaskInstance.test.tsx
new file mode 100644
index 00000000000..7c56371f098
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/TaskInstance.test.tsx
@@ -0,0 +1,144 @@
+/*!
+ * 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 { fireEvent, render, screen } from "@testing-library/react";
+import type { PropsWithChildren } from "react";
+import { Link, MemoryRouter, Route, Routes } from "react-router-dom";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { UseTaskInstanceServiceGetMappedTaskInstanceKeyFn } from 
"openapi/queries";
+import { TaskInstanceService, type TaskInstanceResponse } from 
"openapi/requests";
+
+import { TaskInstance } from "./TaskInstance";
+
+vi.mock("src/hooks/useHITLReviewTabs", () => ({
+  useHITLReviewTabs: vi.fn(() => ({ tabs: [] })),
+}));
+vi.mock("src/hooks/usePluginTabs", () => ({
+  usePluginTabs: vi.fn(() => []),
+}));
+vi.mock("src/hooks/useRequiredActionTabs", () => ({
+  useRequiredActionTabs: vi.fn(() => ({ tabs: [] })),
+}));
+vi.mock("src/layouts/Details/DetailsLayout", () => ({
+  DetailsLayout: ({ children }: PropsWithChildren) => children,
+}));
+vi.mock("src/queries/useGridTISummaries.ts", () => ({
+  useGridTiSummariesStream: vi.fn(() => ({ summariesByRunId: new Map() })),
+}));
+vi.mock("src/utils", async () => {
+  const actual = await vi.importActual("src/utils");
+
+  return {
+    ...actual,
+    useAutoRefresh: vi.fn(() => false),
+    useDocumentTitle: vi.fn(),
+  };
+});
+vi.mock("./Header", () => ({
+  Header: ({ taskInstance }: { readonly taskInstance: TaskInstanceResponse }) 
=> (
+    <div data-testid="task-instance-state">
+      {taskInstance.task_id}:{taskInstance.state ?? 
"none"}:{taskInstance.try_number}
+    </div>
+  ),
+}));
+
+const DAG_ID = "test_dag";
+const DAG_RUN_ID = "test_run";
+const TASK_A = "task_a";
+const TASK_B = "task_b";
+
+const buildTaskInstance = (
+  taskId: string,
+  state: TaskInstanceResponse["state"],
+  tryNumber: number,
+): TaskInstanceResponse =>
+  ({
+    dag_id: DAG_ID,
+    dag_run_id: DAG_RUN_ID,
+    id: `${taskId}-id`,
+    map_index: -1,
+    state,
+    task_display_name: taskId,
+    task_id: taskId,
+    try_number: tryNumber,
+  }) as TaskInstanceResponse;
+
+const buildTaskInstanceKey = (taskId: string) =>
+  UseTaskInstanceServiceGetMappedTaskInstanceKeyFn({
+    dagId: DAG_ID,
+    dagRunId: DAG_RUN_ID,
+    mapIndex: -1,
+    taskId,
+  });
+
+const createWrapper =
+  (queryClient: QueryClient) =>
+  ({ children }: PropsWithChildren) => (
+    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
+  );
+
+afterEach(() => vi.restoreAllMocks());
+
+describe("TaskInstance", () => {
+  it("refetches a cached task instance immediately when switching tasks", 
async () => {
+    const queryClient = new QueryClient({
+      defaultOptions: {
+        queries: {
+          retry: false,
+          staleTime: 5 * 60 * 1000,
+        },
+      },
+    });
+    const cachedTaskA = buildTaskInstance(TASK_A, null, 2);
+    const latestTaskA = buildTaskInstance(TASK_A, "success", 3);
+    const taskB = buildTaskInstance(TASK_B, "success", 1);
+
+    queryClient.setQueryData(buildTaskInstanceKey(TASK_A), cachedTaskA);
+    queryClient.setQueryData(buildTaskInstanceKey(TASK_B), taskB);
+    vi.spyOn(TaskInstanceService, "getMappedTaskInstance").mockImplementation(
+      ({ taskId }) =>
+        Promise.resolve(taskId === TASK_A ? latestTaskA : taskB) as unknown as 
ReturnType<
+          typeof TaskInstanceService.getMappedTaskInstance
+        >,
+    );
+
+    render(
+      <MemoryRouter 
initialEntries={[`/dags/${DAG_ID}/runs/${DAG_RUN_ID}/tasks/${TASK_B}`]}>
+        <Link to={`/dags/${DAG_ID}/runs/${DAG_RUN_ID}/tasks/${TASK_A}`}>Open 
task A</Link>
+        <Routes>
+          <Route element={<TaskInstance />} 
path="/dags/:dagId/runs/:runId/tasks/:taskId" />
+        </Routes>
+      </MemoryRouter>,
+      { wrapper: createWrapper(queryClient) },
+    );
+
+    expect(await screen.findByText(`${TASK_B}:success:1`)).toBeTruthy();
+
+    fireEvent.click(screen.getByRole("link", { name: "Open task A" }));
+
+    expect(await screen.findByText(`${TASK_A}:success:3`)).toBeTruthy();
+    expect(TaskInstanceService.getMappedTaskInstance).toHaveBeenCalledWith({
+      dagId: DAG_ID,
+      dagRunId: DAG_RUN_ID,
+      mapIndex: -1,
+      taskId: TASK_A,
+    });
+  });
+});
diff --git 
a/airflow-core/src/airflow/ui/src/pages/TaskInstance/TaskInstance.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/TaskInstance.tsx
index 80fdd93268e..75371ce6270 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/TaskInstance.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/TaskInstance.tsx
@@ -82,6 +82,7 @@ export const TaskInstance = () => {
     {
       enabled: !isNaN(parsedMapIndex),
       refetchInterval: (query) => (isStatePending(query.state.data?.state) ? 
refetchInterval : false),
+      staleTime: 0,
     },
   );
 

Reply via email to