From b440fe9e5876c53a9093bab63eaa354eb849a2b8 Mon Sep 17 00:00:00 2001 From: Andrew Chang <69671930+Andrushika@users.noreply.github.com> Date: Thu, 6 Aug 2026 02:57:45 +0800 Subject: [PATCH] [v3-3-test] 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. (cherry picked from commit 448c1511ad21657ee364e6276d52d249b70c54af) Co-authored-by: Andrew Chang <69671930+Andrushika@users.noreply.github.com> --- .../src/pages/TaskInstance/Logs/Logs.test.tsx | 93 ++++++++++- .../TaskInstance/Logs/TaskLogContent.tsx | 23 ++- .../TaskInstance/Logs/logSelection.test.ts | 147 ++++++++++++++++++ .../pages/TaskInstance/Logs/logSelection.ts | 73 +++++++++ 4 files changed, 334 insertions(+), 2 deletions(-) create mode 100644 airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/logSelection.test.ts create mode 100644 airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/logSelection.ts 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 f7ae3fc24aa52..a1992708d9151 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 = (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( + , + ); + 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( + , + ); + 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 e2f4986c7e5a7..b720c081ed5ee 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(true); const prevVisibleCountRef = useRef(0); + const pinnedRowsRef = useRef>([]); + + 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 0000000000000..b359f1d1bd205 --- /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 0000000000000..6b0c6afc689cb --- /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 => { + 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, + pinnedIndexes: Array, + count: number, +): Array => { + 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); +};