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 4766f9c8685 Fix slow log scrolling for large task logs (#60806)
4766f9c8685 is described below
commit 4766f9c8685e547b44762f2f5c485489bddae12d
Author: Guan-Ming (Wesley) Chiu <[email protected]>
AuthorDate: Wed Jan 21 21:59:32 2026 +0800
Fix slow log scrolling for large task logs (#60806)
* Fix slow log scrolling for large task logs
* Trim to focus on scroll top/bottom
---
.../src/pages/TaskInstance/Logs/TaskLogContent.tsx | 97 +++++++++++++---------
.../ui/src/pages/TaskInstance/Logs/utils.ts | 51 ++++++++++++
2 files changed, 109 insertions(+), 39 deletions(-)
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 7b79cd89c60..68ae198b2a9 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
@@ -27,6 +27,8 @@ import { ErrorAlert } from "src/components/ErrorAlert";
import { ProgressBar, Tooltip } from "src/components/ui";
import { getMetaKey } from "src/utils";
+import { scrollToBottom, scrollToTop } from "./utils";
+
type Props = {
readonly error: unknown;
readonly isLoading: boolean;
@@ -79,7 +81,8 @@ const ScrollToButton = ({
export const TaskLogContent = ({ error, isLoading, logError, parsedLogs, wrap
}: Props) => {
const hash = location.hash.replace("#", "");
- const parentRef = useRef(null);
+ const parentRef = useRef<HTMLDivElement | null>(null);
+
const rowVirtualizer = useVirtualizer({
count: parsedLogs.length,
estimateSize: () => 20,
@@ -98,8 +101,20 @@ export const TaskLogContent = ({ error, isLoading,
logError, parsedLogs, wrap }:
}, [isLoading, rowVirtualizer, hash, parsedLogs]);
const handleScrollTo = (to: "bottom" | "top") => {
- if (parsedLogs.length > 0) {
- rowVirtualizer.scrollToIndex(to === "bottom" ? parsedLogs.length - 1 :
0);
+ if (parsedLogs.length === 0) {
+ return;
+ }
+
+ const el = rowVirtualizer.scrollElement ?? parentRef.current;
+
+ if (!el) {
+ return;
+ }
+
+ if (to === "top") {
+ scrollToTop({ element: el, virtualizer: rowVirtualizer });
+ } else {
+ scrollToBottom({ element: el, virtualizer: rowVirtualizer });
}
};
@@ -110,49 +125,53 @@ export const TaskLogContent = ({ error, isLoading,
logError, parsedLogs, wrap }:
<Box display="flex" flexDirection="column" flexGrow={1} h="100%"
minHeight={0} position="relative">
<ErrorAlert error={error ?? logError} />
<ProgressBar size="xs" visibility={isLoading ? "visible" : "hidden"} />
- <Code
- css={{
- "& *::selection": {
- bg: "blue.emphasized",
- },
- }}
- data-testid="virtualized-list"
+ <Box
+ data-testid="virtual-scroll-container"
flexGrow={1}
- h="auto"
+ minHeight={0}
overflow="auto"
position="relative"
py={3}
ref={parentRef}
- textWrap={wrap ? "pre" : "nowrap"}
width="100%"
>
- <VStack alignItems="flex-start" gap={0}
h={`${rowVirtualizer.getTotalSize()}px`}>
- {rowVirtualizer.getVirtualItems().map((virtualRow) => (
- <Box
- _ltr={{
- left: 0,
- right: "auto",
- }}
- _rtl={{
- left: "auto",
- right: 0,
- }}
- bgColor={
- Boolean(hash) && virtualRow.index === Number(hash) - 1 ?
"brand.emphasized" : "transparent"
- }
- data-index={virtualRow.index}
- data-testid={`virtualized-item-${virtualRow.index}`}
- key={virtualRow.key}
- position="absolute"
- ref={rowVirtualizer.measureElement}
- top={`${virtualRow.start}px`}
- width={wrap ? "100%" : "max-content"}
- >
- {parsedLogs[virtualRow.index] ?? undefined}
- </Box>
- ))}
- </VStack>
- </Code>
+ <Code
+ css={{
+ "& *::selection": { bg: "blue.emphasized" },
+ }}
+ data-testid="virtualized-list"
+ display="block"
+ textWrap={wrap ? "pre" : "nowrap"}
+ width="100%"
+ >
+ <VStack
+ alignItems="flex-start"
+ gap={0}
+ h={`${rowVirtualizer.getTotalSize()}px`}
+ position="relative"
+ >
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => (
+ <Box
+ _ltr={{ left: 0, right: "auto" }}
+ _rtl={{ left: "auto", right: 0 }}
+ bgColor={
+ Boolean(hash) && virtualRow.index === Number(hash) - 1 ?
"brand.emphasized" : "transparent"
+ }
+ data-index={virtualRow.index}
+ data-testid={`virtualized-item-${virtualRow.index}`}
+ key={virtualRow.key}
+ 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>
{showScrollButtons ? (
<>
diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/utils.ts
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/utils.ts
new file mode 100644
index 00000000000..44fce3cc06c
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/utils.ts
@@ -0,0 +1,51 @@
+/*!
+ * 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 type { Virtualizer } from "@tanstack/react-virtual";
+
+type VirtualizerInstance = Virtualizer<HTMLDivElement, Element>;
+
+type ScrollToTopOptions = {
+ element: HTMLElement;
+ virtualizer: VirtualizerInstance;
+};
+
+type ScrollToBottomOptions = {
+ element: HTMLElement;
+ virtualizer: VirtualizerInstance;
+};
+
+/**
+ * Scroll to the top of the list
+ */
+export const scrollToTop = ({ element, virtualizer }: ScrollToTopOptions):
void => {
+ virtualizer.scrollToOffset(0);
+ element.scrollTop = 0;
+};
+
+/**
+ * Scroll to the bottom of the list
+ */
+export const scrollToBottom = ({ element, virtualizer }:
ScrollToBottomOptions): void => {
+ const totalSize = virtualizer.getTotalSize();
+ const clientHeight = element.clientHeight || 0;
+ const offset = Math.max(0, totalSize - clientHeight);
+
+ virtualizer.scrollToOffset(offset);
+ element.scrollTop = offset;
+};