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 448c1511ad2 Keep task log text selection alive while scrolling (#71148)
448c1511ad2 is described below
commit 448c1511ad21657ee364e6276d52d249b70c54af
Author: Andrew Chang <[email protected]>
AuthorDate: Thu Aug 6 02:57:45 2026 +0800
Keep task log text selection alive while scrolling (#71148)
Virtualized log rows are unmounted as they leave the viewport. This
destroys selection boundaries and makes text selections collapse or drift
during scrolling.
---
.../ui/src/pages/TaskInstance/Logs/Logs.test.tsx | 93 ++++++++++++-
.../src/pages/TaskInstance/Logs/TaskLogContent.tsx | 23 +++-
.../pages/TaskInstance/Logs/logSelection.test.ts | 147 +++++++++++++++++++++
.../ui/src/pages/TaskInstance/Logs/logSelection.ts | 73 ++++++++++
4 files changed, 334 insertions(+), 2 deletions(-)
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 f7ae3fc24aa..a1992708d91 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
@@ -18,7 +18,7 @@
*/
import "@testing-library/jest-dom";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
-import { describe, it, expect, beforeAll } from "vitest";
+import { describe, it, expect, beforeAll, vi } from "vitest";
import { AppWrapper } from "src/utils/AppWrapper";
@@ -438,3 +438,94 @@ describe("Task log search", () => {
await expectRenderedLineNumber(/starting attempt 1 of 3/iu, 3);
}, 10_000);
});
+
+const findRow = (text: string) => {
+ const container = screen.getByTestId("virtual-scroll-container");
+
+ return [...container.querySelectorAll("[data-index]")].find((row) =>
+ row.textContent.includes(text),
+ ) as HTMLElement;
+};
+
+const withFakeSelection = <T,>(selection: Selection, callback: () => T): T => {
+ const getSelectionSpy = vi.spyOn(document,
"getSelection").mockReturnValue(selection);
+ const result = callback();
+
+ getSelectionSpy.mockRestore();
+
+ return result;
+};
+
+describe("Selection pinning across scrolling", () => {
+ it("keeps the selection-anchor row mounted after scrolling it out of the
render window", async () => {
+ render(
+ <AppWrapper
initialEntries={["/dags/log_grouping/runs/manual__2025-02-18T12:19/tasks/generate"]}
/>,
+ );
+ await waitForLogs();
+
+ fireEvent.click(screen.getByTestId("summary-Pre task execution logs"));
+ await waitFor(() => expect(screen.getByText(/starting attempt 1 of
3/iu)).toBeInTheDocument());
+
+ const anchorRow = findRow("Starting attempt 1 of 3");
+ const anchorIndex = Number(anchorRow.getAttribute("data-index"));
+ const neighborIndex = anchorIndex + 1;
+ const textNode = anchorRow.querySelector("span")?.firstChild as Node;
+ const range = document.createRange();
+
+ range.setStart(textNode, 0);
+ range.setEnd(textNode, 0);
+
+ const selection = { getRangeAt: () => range, isCollapsed: true,
rangeCount: 1 } as unknown as Selection;
+
+ withFakeSelection(selection, () => {
+ document.dispatchEvent(new Event("selectionchange"));
+ });
+
+ const container = screen.getByTestId("virtual-scroll-container");
+
+ fireEvent.scroll(container, { target: { scrollTop: ITEM_HEIGHT *
(anchorIndex + 15) } });
+
+ await waitFor(() => {
+
expect(container.querySelector(`[data-index="${neighborIndex}"]`)).toBeNull();
+ });
+
expect(container.querySelector(`[data-index="${anchorIndex}"]`)).not.toBeNull();
+ });
+
+ it("unpins once the selection is cleared", async () => {
+ render(
+ <AppWrapper
initialEntries={["/dags/log_grouping/runs/manual__2025-02-18T12:19/tasks/generate"]}
/>,
+ );
+ await waitForLogs();
+
+ fireEvent.click(screen.getByTestId("summary-Pre task execution logs"));
+ await waitFor(() => expect(screen.getByText(/starting attempt 1 of
3/iu)).toBeInTheDocument());
+
+ const anchorRow = findRow("Starting attempt 1 of 3");
+ const anchorIndex = Number(anchorRow.getAttribute("data-index"));
+ const textNode = anchorRow.querySelector("span")?.firstChild as Node;
+ const range = document.createRange();
+
+ range.setStart(textNode, 0);
+ range.setEnd(textNode, 0);
+
+ const selection = { getRangeAt: () => range, isCollapsed: true,
rangeCount: 1 } as unknown as Selection;
+
+ withFakeSelection(selection, () => {
+ document.dispatchEvent(new Event("selectionchange"));
+ });
+
+ const noSelection = null as unknown as Selection;
+
+ withFakeSelection(noSelection, () => {
+ document.dispatchEvent(new Event("selectionchange"));
+ });
+
+ const container = screen.getByTestId("virtual-scroll-container");
+
+ fireEvent.scroll(container, { target: { scrollTop: ITEM_HEIGHT *
(anchorIndex + 15) } });
+
+ await waitFor(() => {
+
expect(container.querySelector(`[data-index="${anchorIndex}"]`)).toBeNull();
+ });
+ });
+});
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 e2f4986c7e5..b720c081ed5 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,7 +17,8 @@
* under the License.
*/
import { Box, Code, VStack } from "@chakra-ui/react";
-import { useVirtualizer } from "@tanstack/react-virtual";
+import { defaultRangeExtractor, useVirtualizer } from
"@tanstack/react-virtual";
+import type { Range as VirtualizerRange } from "@tanstack/react-virtual";
import { useLayoutEffect, useRef, useCallback, useEffect } from "react";
import { ErrorAlert } from "src/components/ErrorAlert";
@@ -28,6 +29,7 @@ import type { ParsedLogEntry } from "src/queries/useLogs";
import { HighlightedText } from "./HighlightedText";
import { ScrollToButton } from "./ScrollToButton";
+import { getSelectionPinnedRows, mergePinnedIndexes } from "./logSelection";
import { useLogGroups } from "./useLogGroups";
import { getHighlightColor, isSelectionWithin, scrollToBottom, scrollToTop }
from "./utils";
@@ -75,12 +77,17 @@ export const TaskLogContent = ({
const isAtBottomRef = useRef<boolean>(true);
const prevVisibleCountRef = useRef<number>(0);
+ const pinnedRowsRef = useRef<Array<number>>([]);
+
+ const rangeExtractor = (range: VirtualizerRange) =>
+ mergePinnedIndexes(defaultRangeExtractor(range), pinnedRowsRef.current,
range.count);
const rowVirtualizer = useVirtualizer({
count: visibleItems.length,
estimateSize: () => 20,
getScrollElement: () => parentRef.current,
overscan: 10,
+ rangeExtractor,
});
const contentHeight = rowVirtualizer.getTotalSize();
@@ -104,6 +111,20 @@ export const TaskLogContent = ({
return () => el?.removeEventListener("scroll", handleScroll);
}, [handleScroll]);
+ useEffect(() => {
+ const container = parentRef.current;
+ const handleSelectionChange = () => {
+ if (!container) {
+ return;
+ }
+ pinnedRowsRef.current = getSelectionPinnedRows(document.getSelection(),
container);
+ };
+
+ document.addEventListener("selectionchange", handleSelectionChange);
+
+ return () => document.removeEventListener("selectionchange",
handleSelectionChange);
+ }, []);
+
useLayoutEffect(() => {
if (visibleItems.length === 0) {
return;
diff --git
a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/logSelection.test.ts
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/logSelection.test.ts
new file mode 100644
index 00000000000..b359f1d1bd2
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/logSelection.test.ts
@@ -0,0 +1,147 @@
+/*!
+ * 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 { afterEach, describe, expect, it } from "vitest";
+
+import { getSelectionPinnedRows, mergePinnedIndexes } from "./logSelection";
+
+const buildLogContainer = (rows: Array<{ index: number; text: string }>):
HTMLElement => {
+ const container = document.createElement("div");
+
+ rows.forEach(({ index, text }) => {
+ const row = document.createElement("div");
+
+ row.setAttribute("data-index", String(index));
+ row.textContent = text;
+ container.append(row);
+ });
+ document.body.append(container);
+
+ return container;
+};
+
+const getRowTextNode = (container: HTMLElement, index: number): Node =>
+ container.querySelector(`[data-index="${index}"]`)?.firstChild as Node;
+
+const makeSelection = (range: Range): Selection =>
+ ({
+ getRangeAt: () => range,
+ isCollapsed: range.collapsed,
+ rangeCount: 1,
+ }) as unknown as Selection;
+
+type SelectBetweenOptions = {
+ end: Node;
+ endOffset: number;
+ start: Node;
+ startOffset: number;
+};
+
+const selectBetween = ({ end, endOffset, start, startOffset }:
SelectBetweenOptions): Selection => {
+ const range = document.createRange();
+
+ range.setStart(start, startOffset);
+ range.setEnd(end, endOffset);
+
+ return makeSelection(range);
+};
+
+afterEach(() => {
+ document.body.innerHTML = "";
+});
+
+describe("getSelectionPinnedRows", () => {
+ it("pins both rows when both boundaries are inside log rows", () => {
+ const container = buildLogContainer([
+ { index: 2, text: "line 2" },
+ { index: 7, text: "line 7" },
+ ]);
+ const selection = selectBetween({
+ end: getRowTextNode(container, 7),
+ endOffset: 3,
+ start: getRowTextNode(container, 2),
+ startOffset: 1,
+ });
+
+ expect(getSelectionPinnedRows(selection, container)).toEqual([2, 7]);
+ });
+
+ it("keeps the anchor row pinned when the drag focus leaves the rows", () => {
+ const toolbar = document.createElement("div");
+
+ toolbar.textContent = "search toolbar";
+ document.body.prepend(toolbar);
+
+ const container = buildLogContainer([{ index: 100, text: "anchor line" }]);
+
+ const selection = selectBetween({
+ end: getRowTextNode(container, 100),
+ endOffset: 5,
+ start: toolbar.firstChild as Node,
+ startOffset: 0,
+ });
+
+ expect(getSelectionPinnedRows(selection, container)).toEqual([100]);
+ });
+
+ it("pins only the mapped row when one boundary sits on the container
padding", () => {
+ const container = buildLogContainer([
+ { index: 0, text: "line 0" },
+ { index: 5, text: "line 5" },
+ ]);
+ const range = document.createRange();
+
+ range.setStart(container, 0);
+ range.setEnd(getRowTextNode(container, 5), 3);
+
+ expect(getSelectionPinnedRows(makeSelection(range),
container)).toEqual([5]);
+ });
+
+ it("pins the caret row for a collapsed selection so shift-click extension
survives scrolling", () => {
+ const container = buildLogContainer([{ index: 3, text: "caret line" }]);
+ const node = getRowTextNode(container, 3);
+
+ expect(
+ getSelectionPinnedRows(
+ selectBetween({ end: node, endOffset: 2, start: node, startOffset: 2
}),
+ container,
+ ),
+ ).toEqual([3, 3]);
+ });
+
+ it("returns no pins for a null selection", () => {
+ const container = buildLogContainer([{ index: 0, text: "line 0" }]);
+
+ expect(getSelectionPinnedRows(null, container)).toEqual([]);
+ });
+});
+
+describe("mergePinnedIndexes", () => {
+ it("returns the default range untouched when there is nothing to pin", () =>
{
+ expect(mergePinnedIndexes([5, 6, 7], [], 10)).toEqual([5, 6, 7]);
+ });
+
+ it("merges pinned indexes into the range, sorted and deduplicated", () => {
+ expect(mergePinnedIndexes([5, 6, 7], [12, 2, 6], 20)).toEqual([2, 5, 6, 7,
12]);
+ });
+
+ it("drops pinned indexes outside [0, count)", () => {
+ expect(mergePinnedIndexes([5, 6], [-1, 99], 10)).toEqual([5, 6]);
+ expect(mergePinnedIndexes([5, 6], [-1, 2, 99], 10)).toEqual([2, 5, 6]);
+ });
+});
diff --git
a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/logSelection.ts
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/logSelection.ts
new file mode 100644
index 00000000000..6b0c6afc689
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/logSelection.ts
@@ -0,0 +1,73 @@
+/*!
+ * 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.
+ */
+
+/**
+ * Map a DOM node inside the virtualized log list to the `data-index` of the
+ * row containing it.
+ */
+export const getRowIndexForNode = (node: Node | null, container: HTMLElement):
number | undefined => {
+ const element = node instanceof Element ? node : node?.parentElement;
+ const row = element?.closest("[data-index]");
+
+ if (!row || !container.contains(row)) {
+ return undefined;
+ }
+ const index = Number(row.getAttribute("data-index"));
+
+ return Number.isInteger(index) ? index : undefined;
+};
+
+/**
+ * Row indexes to pin so the virtualizer keeps selection-boundary rows
+ * mounted. Boundaries map independently (the drag focus may sit off the
+ * rows) and a collapsed caret pins too, for shift-click extension.
+ */
+export const getSelectionPinnedRows = (
+ selection: Selection | null,
+ container: HTMLElement,
+): Array<number> => {
+ if (!selection || selection.rangeCount === 0) {
+ return [];
+ }
+ const range = selection.getRangeAt(0);
+
+ return [
+ getRowIndexForNode(range.startContainer, container),
+ getRowIndexForNode(range.endContainer, container),
+ ].filter((index): index is number => index !== undefined);
+};
+
+/**
+ * Merge selection-pinned row indexes into the virtualizer's default render
+ * range. Rows holding selection boundaries must stay mounted while the user
+ * scrolls — unmounting a boundary node collapses the browser selection.
+ */
+export const mergePinnedIndexes = (
+ defaultIndexes: Array<number>,
+ pinnedIndexes: Array<number>,
+ count: number,
+): Array<number> => {
+ const validPins = pinnedIndexes.filter((index) => index >= 0 && index <
count);
+
+ if (validPins.length === 0) {
+ return defaultIndexes;
+ }
+
+ return [...new Set([...validPins, ...defaultIndexes])].sort((first, second)
=> first - second);
+};