Repository: apache/airflow
Pull request #50746: Update `TaskLogContent` to support virtualized rendering
Author: login: guan404ming
Labels: name: area:UI
description: Related to UI/UX. For Frontend Developers.
Created: 2025-05-18T07:57:51Z | Merged: 2025-05-29T14:40:18Z

<!--
 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.
 -->

<!--
Thank you for contributing! Please make sure that your code changes
are covered with tests. And in case of new features or big changes
remember to adjust the documentation.

Feel free to ping committers for the review!

In case of an existing issue, reference it using one of the following:

closes: #ISSUE
related: #ISSUE

How to write a good git commit message:
http://chris.beams.io/posts/git-commit/
-->

## Related Issue

#50333
cc @bbovenzi @pierrejeambrun 

## Why

The log rendering would be quite slow or even crash browser when rendering large logs on the frontend

## How

By using [@tanstack/react-virtual](https://github.com/TanStack/virtual) to support virtualized rendering, I mostly follow this [official example](https://tanstack.com/virtual/latest/docs/framework/react/examples/dynamic?panel=sandbox) to implement. I choose to use dynamic rendering instead of specifying fixed height since our log would have different height.

--> about 7x speed up (for 10000 logs on my local machine and tested 10 times)
Calculated from the moment the browser logs the parsed logs to the console until it’s finally rendered on the screen. Although this isn’t perfectly precise, it still gives a simple metric to confirm that our speed-up trend is positive.

before -> avg 7s 

https://github.com/user-attachments/assets/520a49ba-4522-4501-bfdd-593e5d0cdb05

after -> avg 1s

https://github.com/user-attachments/assets/4dd9c48f-be7b-4112-9da2-b94b805b4829

Minor change: add `accept` field for `useLog`, which make us to support `ndjson` easily by changing the field value and adding parse func for it after it is fully supported in the future.


<!-- Please keep an empty line above the dashes. -->
---
**^ Add meaningful description above**
Read the **[Pull Request Guidelines](https://github.com/apache/airflow/blob/main/contributing-docs/05_pull_requests.rst#pull-request-guidelines)** for more information.
In case of fundamental code changes, an Airflow Improvement Proposal ([AIP](https://cwiki.apache.org/confluence/display/AIRFLOW/Airflow+Improvement+Proposals)) is needed.
In case of a new dependency, check compliance with the [ASF 3rd Party License Policy](https://www.apache.org/legal/resolved.html#category-x).
In case of backwards incompatible changes please leave a note in a newsfragment file, named `{pr_number}.significant.rst` or `{issue_number}.significant.rst`, in [airflow-core/newsfragments](https://github.com/apache/airflow/tree/main/airflow-core/newsfragments).



Diff:
diff --git a/airflow-core/src/airflow/ui/package.json b/airflow-core/src/airflow/ui/package.json
index 812419bdda522..f37db48d9dc0f 100644
--- a/airflow-core/src/airflow/ui/package.json
+++ b/airflow-core/src/airflow/ui/package.json
@@ -22,6 +22,7 @@
     "@emotion/react": "^11.14.0",
     "@tanstack/react-query": "^5.75.1",
     "@tanstack/react-table": "^8.21.3",
+    "@tanstack/react-virtual": "^3.13.8",
     "@types/debounce-promise": "^3.1.9",
     "@uiw/codemirror-themes-all": "^4.23.12",
     "@uiw/react-codemirror": "^4.23.12",
diff --git a/airflow-core/src/airflow/ui/pnpm-lock.yaml b/airflow-core/src/airflow/ui/pnpm-lock.yaml
index 7b8af620e5455..0c0c1b55bf9c8 100644
--- a/airflow-core/src/airflow/ui/pnpm-lock.yaml
+++ b/airflow-core/src/airflow/ui/pnpm-lock.yaml
@@ -26,6 +26,9 @@ importers:
       '@tanstack/react-table':
         specifier: ^8.21.3
         version: 8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+      '@tanstack/react-virtual':
+        specifier: ^3.13.8
+        version: 3.13.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
       '@types/debounce-promise':
         specifier: ^3.1.9
         version: 3.1.9
@@ -995,10 +998,19 @@ packages:
       react: '>=16.8'
       react-dom: '>=16.8'
 
+  '@tanstack/react-virtual@3.13.8':
+    resolution: {integrity: sha512-meS2AanUg50f3FBSNoAdBSRAh8uS0ue01qm7zrw65KGJtiXB9QXfybqZwkh4uFpRv2iX/eu5tjcH5wqUpwYLPg==}
+    peerDependencies:
+      react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+      react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
   '@tanstack/table-core@8.21.3':
     resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
     engines: {node: '>=12'}
 
+  '@tanstack/virtual-core@3.13.8':
+    resolution: {integrity: sha512-BT6w89Hqy7YKaWewYzmecXQzcJh6HTBbKYJIIkMaNU49DZ06LoTV3z32DWWEdUsgW6n1xTmwTLs4GtWrZC261w==}
+
   '@testing-library/dom@10.4.0':
     resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==}
     engines: {node: '>=18'}
@@ -5279,8 +5291,16 @@ snapshots:
       react: 18.3.1
       react-dom: 18.3.1(react@18.3.1)
 
+  '@tanstack/react-virtual@3.13.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+    dependencies:
+      '@tanstack/virtual-core': 3.13.8
+      react: 18.3.1
+      react-dom: 18.3.1(react@18.3.1)
+
   '@tanstack/table-core@8.21.3': {}
 
+  '@tanstack/virtual-core@3.13.8': {}
+
   '@testing-library/dom@10.4.0':
     dependencies:
       '@babel/code-frame': 7.27.1
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Overview/TaskLogPreview.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/Overview/TaskLogPreview.tsx
index 979f6bcfddff7..a4b82e1680369 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Overview/TaskLogPreview.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Overview/TaskLogPreview.tsx
@@ -67,7 +67,7 @@ export const TaskLogPreview = ({
           error={error}
           isLoading={isLoading}
           logError={error}
-          parsedLogs={data.parsedLogs}
+          parsedLogs={data.parsedLogs ?? []}
           wrap={wrap}
         />
       </Box>
diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.test.tsx b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.test.tsx
index e39b98cbaf99a..af613d9dc61d3 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.test.tsx
@@ -17,7 +17,7 @@
  * under the License.
  */
 import "@testing-library/jest-dom";
-import { render, screen, waitFor } from "@testing-library/react";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
 import { setupServer, type SetupServerApi } from "msw/node";
 import { afterEach, describe, it, expect, beforeAll, afterAll } from "vitest";
 
@@ -25,10 +25,17 @@ import { handlers } from "src/mocks/handlers";
 import { AppWrapper } from "src/utils/AppWrapper";
 
 let server: SetupServerApi;
+const ITEM_HEIGHT = 20;
 
 beforeAll(() => {
   server = setupServer(...handlers);
   server.listen({ onUnhandledRequest: "bypass" });
+  Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
+    value: ITEM_HEIGHT,
+  });
+  Object.defineProperty(HTMLElement.prototype, "offsetWidth", {
+    value: 800,
+  });
 });
 
 afterEach(() => server.resetHandlers());
@@ -39,14 +46,18 @@ describe("Task log grouping", () => {
     render(
       <AppWrapper initialEntries={["/dags/log_grouping/runs/manual__2025-02-18T12:19/tasks/generate"]} />,
     );
+    await waitFor(() => expect(screen.queryByTestId("virtualized-list")).toBeInTheDocument());
+    await waitFor(() => expect(screen.queryByTestId("virtualized-item-0")).toBeInTheDocument());
+    await waitFor(() => expect(screen.queryByTestId("virtualized-item-10")).toBeInTheDocument());
 
-    await waitFor(() => expect(screen.queryByTestId("summary-Pre task execution logs")).toBeInTheDocument(), {
-      timeout: 10_000,
-    });
+    fireEvent.scroll(screen.getByTestId("virtualized-list"), { target: { scrollTop: ITEM_HEIGHT * 6 } });
+    await waitFor(() => expect(screen.queryByTestId("virtualized-item-16")).toBeInTheDocument());
+
+    await waitFor(() => expect(screen.queryByTestId("summary-Pre task execution logs")).toBeInTheDocument());
     await waitFor(() => expect(screen.getByTestId("summary-Pre task execution logs")).toBeVisible());
     await waitFor(() => expect(screen.queryByText(/Task instance is in running state/iu)).not.toBeVisible());
 
     await waitFor(() => screen.getByTestId("summary-Pre task execution logs").click());
     await waitFor(() => expect(screen.queryByText(/Task instance is in running state/iu)).toBeVisible());
-  });
+  }, 10_000);
 });
diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.tsx b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.tsx
index c7214ed8bea8c..25fce170dd063 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.tsx
@@ -92,7 +92,7 @@ export const Logs = () => {
   const showExternalLogRedirect = Boolean(useConfig("show_external_log_redirect"));
 
   return (
-    <Box p={2}>
+    <Box display="flex" flexDirection="column" h="100%" p={2}>
       <TaskLogHeader
         onSelectTryNumber={onSelectTryNumber}
         sourceOptions={data.sources}
@@ -117,7 +117,7 @@ export const Logs = () => {
         error={error}
         isLoading={isLoading || isLoadingLogs}
         logError={logError}
-        parsedLogs={data.parsedLogs}
+        parsedLogs={data.parsedLogs ?? []}
         wrap={wrap}
       />
       <Dialog.Root onOpenChange={onOpenChange} open={fullscreen} scrollBehavior="inside" size="full">
@@ -139,12 +139,12 @@ export const Logs = () => {
 
           <Dialog.CloseTrigger />
 
-          <Dialog.Body>
+          <Dialog.Body display="flex" flexDirection="column">
             <TaskLogContent
               error={error}
               isLoading={isLoading || isLoadingLogs}
               logError={logError}
-              parsedLogs={data.parsedLogs}
+              parsedLogs={data.parsedLogs ?? []}
               wrap={wrap}
             />
           </Dialog.Body>
diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogContent.tsx b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogContent.tsx
index db62882ad08ad..e2c5109ab414c 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogContent.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogContent.tsx
@@ -17,8 +17,8 @@
  * under the License.
  */
 import { Box, Code, VStack, useToken } from "@chakra-ui/react";
-import type { ReactNode } from "react";
-import { useLayoutEffect } from "react";
+import { useVirtualizer } from "@tanstack/react-virtual";
+import { useLayoutEffect, useRef } from "react";
 
 import { ErrorAlert } from "src/components/ErrorAlert";
 import { ProgressBar } from "src/components/ui";
@@ -27,12 +27,19 @@ type Props = {
   readonly error: unknown;
   readonly isLoading: boolean;
   readonly logError: unknown;
-  readonly parsedLogs: ReactNode;
+  readonly parsedLogs: Array<JSX.Element | string | undefined>;
   readonly wrap: boolean;
 };
 
 export const TaskLogContent = ({ error, isLoading, logError, parsedLogs, wrap }: Props) => {
   const [bgLine] = useToken("colors", ["blue.emphasized"]);
+  const parentRef = useRef(null);
+  const rowVirtualizer = useVirtualizer({
+    count: parsedLogs.length,
+    estimateSize: () => 20,
+    getScrollElement: () => parentRef.current,
+    overscan: 10,
+  });
 
   useLayoutEffect(() => {
     if (location.hash) {
@@ -53,7 +60,7 @@ export const TaskLogContent = ({ error, isLoading, logError, parsedLogs, wrap }:
   }, [isLoading, bgLine]);
 
   return (
-    <Box>
+    <Box display="flex" flexDirection="column" flexGrow={1} h="100%" minHeight={0}>
       <ErrorAlert error={error ?? logError} />
       <ProgressBar size="xs" visibility={isLoading ? "visible" : "hidden"} />
       <Code
@@ -62,13 +69,32 @@ export const TaskLogContent = ({ error, isLoading, logError, parsedLogs, wrap }:
             bg: "blue.subtle",
           },
         }}
+        data-testid="virtualized-list"
+        flexGrow={1}
+        h="auto"
         overflow="auto"
+        position="relative"
         py={3}
+        ref={parentRef}
         textWrap={wrap ? "pre" : "nowrap"}
         width="100%"
       >
-        <VStack alignItems="flex-start" gap={0}>
-          {parsedLogs}
+        <VStack alignItems="flex-start" gap={0} h={`${rowVirtualizer.getTotalSize()}px`}>
+          {rowVirtualizer.getVirtualItems().map((virtualRow) => (
+            <Box
+              data-index={virtualRow.index}
+              data-testid={`virtualized-item-${virtualRow.index}`}
+              key={virtualRow.key}
+              left={0}
+              position="absolute"
+              ref={rowVirtualizer.measureElement}
+              top={0}
+              transform={`translateY(${virtualRow.start}px)`}
+              width={wrap ? "100%" : "max-content"}
+            >
+              {parsedLogs[virtualRow.index] ?? undefined}
+            </Box>
+          ))}
         </VStack>
       </Code>
     </Box>
diff --git a/airflow-core/src/airflow/ui/src/queries/useLogs.tsx b/airflow-core/src/airflow/ui/src/queries/useLogs.tsx
index 3007deee70d9a..01b476f93c957 100644
--- a/airflow-core/src/airflow/ui/src/queries/useLogs.tsx
+++ b/airflow-core/src/airflow/ui/src/queries/useLogs.tsx
@@ -28,6 +28,7 @@ import { isStatePending, useAutoRefresh } from "src/utils";
 import { getTaskInstanceLink } from "src/utils/links";
 
 type Props = {
+  accept?: "*/*" | "application/json" | "application/x-ndjson";
   dagId: string;
   logLevelFilters?: Array<string>;
   sourceFilters?: Array<string>;
@@ -120,13 +121,14 @@ const parseLogs = ({ data, logLevelFilters, sourceFilters, taskInstance, tryNumb
 };
 
 export const useLogs = (
-  { dagId, logLevelFilters, sourceFilters, taskInstance, tryNumber = 1 }: Props,
+  { accept = "application/json", dagId, logLevelFilters, sourceFilters, taskInstance, tryNumber = 1 }: Props,
   options?: Omit<UseQueryOptions<TaskInstancesLogResponse>, "queryFn" | "queryKey">,
 ) => {
   const refetchInterval = useAutoRefresh({ dagId });
 
   const { data, ...rest } = useTaskInstanceServiceGetLog(
     {
+      accept,
       dagId,
       dagRunId: taskInstance?.dag_run_id ?? "",
       mapIndex: taskInstance?.map_index ?? -1,


Reviews:
user: login: guan404ming
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-18T17:04:28Z
user: login: guan404ming
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-19T07:28:24Z
user: login: bbovenzi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T20:33:18Z
user: login: bbovenzi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T20:34:06Z
user: login: bbovenzi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T20:35:44Z
user: login: guan404ming
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-25T16:19:49Z
user: login: guan404ming
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-25T16:22:43Z
user: login: guan404ming
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-25T16:42:51Z
user: login: bbovenzi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-28T18:52:17Z
user: login: guan404ming
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-29T12:46:16Z
user: login: bbovenzi
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-29T14:15:44Z

Review comments:
diff_hunk: @@ -39,14 +45,16 @@ describe("Task log grouping", () => {
     render(
       <AppWrapper initialEntries={["/dags/log_grouping/runs/manual__2025-02-18T12:19/tasks/generate"]} />,
     );
+    await waitFor(() => expect(screen.queryByTestId("virtualized-list")).toBeInTheDocument());
+    await waitFor(() => expect(screen.queryByTestId("virtualized-item-0")).toBeInTheDocument());
+    fireEvent.scroll(screen.getByTestId("virtualized-list"), { target: { scrollTop: 4050 } });
path: airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.test.tsx
user: login: guan404ming
body: This test follow the [official test](https://github.com/TanStack/virtual/issues/641#issuecomment-2851908893) then it works after a bunch of time of modification. But I’m still not really clear on why it needs to be 4050 px. That value came from manual testing. One possible explanation is that the row height in our tests doesn’t match what the browser actually renders (320px + 160px).
created_at: 2025-05-18T17:04:28Z
author_association: MEMBER
diff_hunk: @@ -39,14 +45,16 @@ describe("Task log grouping", () => {
     render(
       <AppWrapper initialEntries={["/dags/log_grouping/runs/manual__2025-02-18T12:19/tasks/generate"]} />,
     );
+    await waitFor(() => expect(screen.queryByTestId("virtualized-list")).toBeInTheDocument());
+    await waitFor(() => expect(screen.queryByTestId("virtualized-item-0")).toBeInTheDocument());
+    fireEvent.scroll(screen.getByTestId("virtualized-list"), { target: { scrollTop: 4050 } });
path: airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.test.tsx
user: login: guan404ming
body: Resolved, it would use the `offsetHeight` as item height and turns out to be 120 = 20 (height) * 6 (item). Thus, using ITEM_HEIGHT * 6 would be more reasonable.
created_at: 2025-05-19T07:28:24Z
author_association: MEMBER
diff_hunk: @@ -17,22 +17,39 @@
  * under the License.
  */
 import { Box, Code, VStack, useToken } from "@chakra-ui/react";
-import type { ReactNode } from "react";
-import { useLayoutEffect } from "react";
+import { useVirtualizer } from "@tanstack/react-virtual";
+import { useLayoutEffect, useRef } from "react";
 
 import { ErrorAlert } from "src/components/ErrorAlert";
 import { ProgressBar } from "src/components/ui";
 
+const EMPTY_ARRAY: Array<JSX.Element | string | undefined> = [];
+
 type Props = {
   readonly error: unknown;
+  readonly height?: number | string;
   readonly isLoading: boolean;
   readonly logError: unknown;
-  readonly parsedLogs: ReactNode;
+  readonly parsedLogs: Array<JSX.Element | string | undefined> | undefined;
   readonly wrap: boolean;
 };
 
-export const TaskLogContent = ({ error, isLoading, logError, parsedLogs, wrap }: Props) => {
+export const TaskLogContent = ({
+  error,
+  height = "600px",
+  isLoading,
+  logError,
+  parsedLogs = EMPTY_ARRAY,
+  wrap,
+}: Props) => {
   const [bgLine] = useToken("colors", ["blue.emphasized"]);
+  const parentRef = useRef(null);
+  const rowVirtualizer = useVirtualizer({
+    count: parsedLogs.length,
+    estimateSize: () => 20,
path: airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogContent.tsx
user: login: bbovenzi
body: Can you add a video of the Task Log previews we have in the Dag Overview page? We should make sure the visible portion of the virtualized list is shorter for those.
created_at: 2025-05-22T20:33:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -120,13 +121,14 @@ const parseLogs = ({ data, logLevelFilters, sourceFilters, taskInstance, tryNumb
 };
 
 export const useLogs = (
-  { dagId, logLevelFilters, sourceFilters, taskInstance, tryNumber = 1 }: Props,
+  { accept, dagId, logLevelFilters, sourceFilters, taskInstance, tryNumber = 1 }: Props,
   options?: Omit<UseQueryOptions<TaskInstancesLogResponse>, "queryFn" | "queryKey">,
 ) => {
   const refetchInterval = useAutoRefresh({ dagId });
 
   const { data, ...rest } = useTaskInstanceServiceGetLog(
     {
+      accept: accept ?? "application/json",
path: airflow-core/src/airflow/ui/src/queries/useLogs.tsx
user: login: bbovenzi
body: ```suggestion
  { accept = "application/json", dagId, logLevelFilters, sourceFilters, taskInstance, tryNumber = 1 }: Props,
  options?: Omit<UseQueryOptions<TaskInstancesLogResponse>, "queryFn" | "queryKey">,
) => {
  const refetchInterval = useAutoRefresh({ dagId });

  const { data, ...rest } = useTaskInstanceServiceGetLog(
    {
      accept,
```

Nit
created_at: 2025-05-22T20:34:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -115,6 +116,7 @@ export const Logs = () => {
       ) : undefined}
       <TaskLogContent
         error={error}
+        height="600px"
path: airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.tsx
user: login: bbovenzi
body: I'm not sure if we can reliably set the height like this. It would become a very small area on large monitor screens
created_at: 2025-05-22T20:35:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -120,13 +121,14 @@ const parseLogs = ({ data, logLevelFilters, sourceFilters, taskInstance, tryNumb
 };
 
 export const useLogs = (
-  { dagId, logLevelFilters, sourceFilters, taskInstance, tryNumber = 1 }: Props,
+  { accept, dagId, logLevelFilters, sourceFilters, taskInstance, tryNumber = 1 }: Props,
   options?: Omit<UseQueryOptions<TaskInstancesLogResponse>, "queryFn" | "queryKey">,
 ) => {
   const refetchInterval = useAutoRefresh({ dagId });
 
   const { data, ...rest } = useTaskInstanceServiceGetLog(
     {
+      accept: accept ?? "application/json",
path: airflow-core/src/airflow/ui/src/queries/useLogs.tsx
user: login: guan404ming
body: Updated!
created_at: 2025-05-25T16:19:49Z
author_association: MEMBER
diff_hunk: @@ -115,6 +116,7 @@ export const Logs = () => {
       ) : undefined}
       <TaskLogContent
         error={error}
+        height="600px"
path: airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.tsx
user: login: guan404ming
body: I use `h={"full"}` at first but the virtualization doesn't work as expected. After multiple trials and searching, I follow the discussion [here](https://github.com/TanStack/virtual/issues/25) to implement with adding some flex and height related css for parent element and it works~
created_at: 2025-05-25T16:22:43Z
author_association: MEMBER
diff_hunk: @@ -17,22 +17,39 @@
  * under the License.
  */
 import { Box, Code, VStack, useToken } from "@chakra-ui/react";
-import type { ReactNode } from "react";
-import { useLayoutEffect } from "react";
+import { useVirtualizer } from "@tanstack/react-virtual";
+import { useLayoutEffect, useRef } from "react";
 
 import { ErrorAlert } from "src/components/ErrorAlert";
 import { ProgressBar } from "src/components/ui";
 
+const EMPTY_ARRAY: Array<JSX.Element | string | undefined> = [];
+
 type Props = {
   readonly error: unknown;
+  readonly height?: number | string;
   readonly isLoading: boolean;
   readonly logError: unknown;
-  readonly parsedLogs: ReactNode;
+  readonly parsedLogs: Array<JSX.Element | string | undefined> | undefined;
   readonly wrap: boolean;
 };
 
-export const TaskLogContent = ({ error, isLoading, logError, parsedLogs, wrap }: Props) => {
+export const TaskLogContent = ({
+  error,
+  height = "600px",
+  isLoading,
+  logError,
+  parsedLogs = EMPTY_ARRAY,
+  wrap,
+}: Props) => {
   const [bgLine] = useToken("colors", ["blue.emphasized"]);
+  const parentRef = useRef(null);
+  const rowVirtualizer = useVirtualizer({
+    count: parsedLogs.length,
+    estimateSize: () => 20,
path: airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogContent.tsx
user: login: guan404ming
body: Here is the video of `TaskTogPreview`. It seems work well for the preview

https://github.com/user-attachments/assets/00de8c00-6331-452b-9afb-4c7f1b074d30
created_at: 2025-05-25T16:42:51Z
author_association: MEMBER
diff_hunk: @@ -17,22 +17,31 @@
  * under the License.
  */
 import { Box, Code, VStack, useToken } from "@chakra-ui/react";
-import type { ReactNode } from "react";
-import { useLayoutEffect } from "react";
+import { useVirtualizer } from "@tanstack/react-virtual";
+import { useLayoutEffect, useRef } from "react";
 
 import { ErrorAlert } from "src/components/ErrorAlert";
 import { ProgressBar } from "src/components/ui";
 
+const EMPTY_ARRAY: Array<JSX.Element | string | undefined> = [];
+
 type Props = {
   readonly error: unknown;
   readonly isLoading: boolean;
   readonly logError: unknown;
-  readonly parsedLogs: ReactNode;
+  readonly parsedLogs: Array<JSX.Element | string | undefined> | undefined;
   readonly wrap: boolean;
 };
 
-export const TaskLogContent = ({ error, isLoading, logError, parsedLogs, wrap }: Props) => {
+export const TaskLogContent = ({ error, isLoading, logError, parsedLogs = EMPTY_ARRAY, wrap }: Props) => {
path: airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogContent.tsx
user: login: bbovenzi
body: Why do we need to declare `EMPTY_ARRAY` instead of just `parsedLogs = []`?
created_at: 2025-05-28T18:52:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -17,22 +17,31 @@
  * under the License.
  */
 import { Box, Code, VStack, useToken } from "@chakra-ui/react";
-import type { ReactNode } from "react";
-import { useLayoutEffect } from "react";
+import { useVirtualizer } from "@tanstack/react-virtual";
+import { useLayoutEffect, useRef } from "react";
 
 import { ErrorAlert } from "src/components/ErrorAlert";
 import { ProgressBar } from "src/components/ui";
 
+const EMPTY_ARRAY: Array<JSX.Element | string | undefined> = [];
+
 type Props = {
   readonly error: unknown;
   readonly isLoading: boolean;
   readonly logError: unknown;
-  readonly parsedLogs: ReactNode;
+  readonly parsedLogs: Array<JSX.Element | string | undefined> | undefined;
   readonly wrap: boolean;
 };
 
-export const TaskLogContent = ({ error, isLoading, logError, parsedLogs, wrap }: Props) => {
+export const TaskLogContent = ({ error, isLoading, logError, parsedLogs = EMPTY_ARRAY, wrap }: Props) => {
path: airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogContent.tsx
user: login: guan404ming
body: To use variable is to prevent [this](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/no-object-type-as-default-prop.md) but I think the way is not really straightforward. Thus, I change it into not accepting undefined here and pass `parsedLogs ?? []`, which is more align to our codebase and clear.
created_at: 2025-05-29T12:46:16Z
author_association: MEMBER

PR comments:
user: login: jscheffl
created_at: 2025-05-18T08:56:32Z
body: Cool! I feel not confident to review but very great improvement!
Test needs to be fixed though.
author_association: CONTRIBUTOR
user: login: guan404ming
created_at: 2025-05-18T08:59:17Z
body: Thanks! I'm still trying to figure out how to test virtualized list.
author_association: MEMBER
user: login: aritra24
created_at: 2025-05-18T11:06:06Z
body: Nice change! Good job!

author_association: COLLABORATOR
user: login: guan404ming
created_at: 2025-05-25T16:45:22Z
body: Currently the list is worked in large monitor as in the small screen

Here are screenshots on 27 inch monitor
![image](https://github.com/user-attachments/assets/244b91e8-d214-4227-b70e-ea457f0af073)
![image](https://github.com/user-attachments/assets/38ecf7da-bc57-4f4d-b759-64aa2b9654be)

author_association: MEMBER
user: login: bbovenzi
created_at: 2025-05-28T18:53:48Z
body: Looks good overall. Just one nit.

And then, I wonder if we want this in 3.0.2 instead of 3.1.0? Its an enhancement, not a new feature.
author_association: CONTRIBUTOR
user: login: guan404ming
created_at: 2025-05-29T12:59:30Z
body: > And then, I wonder if we want this in 3.0.2 instead of 3.1.0? Its an enhancement, not a new feature.

I think it should go into 3.0.2 since it doesn’t affect how users interact with the component. I’d consider it more of an enhancement for ux. Imo the new feature would be the upcoming ndjson-related changes after https://github.com/apache/airflow/pull/49470 
author_association: MEMBER
user: login: github-actions[bot]
created_at: 2025-05-29T14:41:13Z
body: ### Backport successfully created: v3-0-test

<table>
                <tr>
                    <th>Status</th>
                    <th>Branch</th>
                    <th>Result</th>
                </tr>
                <tr>
                    <td>✅</td>
                    <td>v3-0-test</td>
                    <td><a href="https://github.com/apache/airflow/pull/51202"><img src="https://img.shields.io/badge/PR-51202-blue" alt="PR Link"></a></td>
                </tr>
            </table>
author_association: CONTRIBUTOR
user: login: guan404ming
created_at: 2025-05-29T14:42:18Z
body: Thanks!
author_association: MEMBER
user: login: jason810496
created_at: 2025-06-01T07:11:02Z
body: Big thanks @guan404ming 🙌  That's a fantastic improvement, really appreciate your work on this!
author_association: MEMBER