This is an automated email from the ASF dual-hosted git repository.
vatsrahul1001 pushed a commit to branch v3-3-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-3-test by this push:
new e105f2cdc97 [v3-3-test] UI: Refresh task details immediately when
switching tasks (#70789) (#71012)
e105f2cdc97 is described below
commit e105f2cdc971d0cc2821d6ad31a1a39728bef1d4
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Tue Aug 4 17:27:42 2026 +0530
[v3-3-test] UI: Refresh task details immediately when switching tasks
(#70789) (#71012)
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.
(cherry picked from commit a14105b25926246a8d61ecb494c880b477e6f862)
Co-authored-by: Andrew Chang <[email protected]>
Co-authored-by: Rahul Vats <[email protected]>
---
.../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,
},
);