From 7fdccc97b1864a078ba34519cb5bda11d0c6ad9f Mon Sep 17 00:00:00 2001 From: Shivam Rastogi <6463385+shivaam@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:42:28 -0700 Subject: [PATCH 1/3] Render DAG card content near the viewport --- .../ui/src/hooks/useNearViewport.test.tsx | 167 ++++++++++++++++++ .../airflow/ui/src/hooks/useNearViewport.ts | 97 ++++++++++ .../ui/src/pages/DagsList/DagCard.test.tsx | 85 ++++++++- .../airflow/ui/src/pages/DagsList/DagCard.tsx | 63 +++---- .../ui/src/pages/DagsList/DagCardActions.tsx | 39 ++++ 5 files changed, 416 insertions(+), 35 deletions(-) create mode 100644 airflow-core/src/airflow/ui/src/hooks/useNearViewport.test.tsx create mode 100644 airflow-core/src/airflow/ui/src/hooks/useNearViewport.ts create mode 100644 airflow-core/src/airflow/ui/src/pages/DagsList/DagCardActions.tsx diff --git a/airflow-core/src/airflow/ui/src/hooks/useNearViewport.test.tsx b/airflow-core/src/airflow/ui/src/hooks/useNearViewport.test.tsx new file mode 100644 index 0000000000000..67cba3caffe49 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/hooks/useNearViewport.test.tsx @@ -0,0 +1,167 @@ +/*! + * 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 { act, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { useNearViewport } from "./useNearViewport"; + +type MockObserver = { + callback: IntersectionObserverCallback; +} & IntersectionObserver; + +const observers: Array = []; + +const createEntry = (target: Element, isIntersecting: boolean): IntersectionObserverEntry => ({ + boundingClientRect: target.getBoundingClientRect(), + intersectionRatio: isIntersecting ? 1 : 0, + intersectionRect: target.getBoundingClientRect(), + isIntersecting, + rootBounds: null, + target, + time: 0, +}); + +const takeNoRecords = () => []; + +const MockIntersectionObserver = function MockIntersectionObserver( + callback: IntersectionObserverCallback, + options?: IntersectionObserverInit, +): IntersectionObserver { + const observer = { + callback, + disconnect: vi.fn(), + observe: vi.fn(), + root: null, + rootMargin: options?.rootMargin ?? "0px", + scrollMargin: options?.scrollMargin ?? "0px", + takeRecords: vi.fn(takeNoRecords), + thresholds: [0], + unobserve: vi.fn(), + } satisfies MockObserver; + + observers.push(observer); + + return observer; +}; + +const installIntersectionObserver = () => { + vi.stubGlobal("IntersectionObserver", MockIntersectionObserver); +}; + +const TestCard = ({ name }: { readonly name: string }) => { + const { isNearViewport, ref } = useNearViewport(); + + return ( +
+ {name} + {isNearViewport ? {`${name} controls`} : {`${name} placeholder`}} +
+ ); +}; + +afterEach(() => { + observers.length = 0; + vi.unstubAllGlobals(); +}); + +describe("useNearViewport", () => { + it("mounts deferred content when its shell approaches the viewport and keeps it mounted", () => { + installIntersectionObserver(); + render(); + + expect(screen.getByTestId("first-shell")).toBeInTheDocument(); + expect(screen.getByText("first placeholder")).toBeInTheDocument(); + expect(screen.queryByText("first controls")).not.toBeInTheDocument(); + expect(observers).toHaveLength(1); + expect(observers[0]?.rootMargin).toBe("600px 0px"); + expect(observers[0]?.scrollMargin).toBe("600px 0px"); + + const [observer] = observers; + const shell = screen.getByTestId("first-shell"); + + if (observer === undefined) { + throw new Error("Expected an IntersectionObserver"); + } + + act(() => { + observer.callback([createEntry(shell, true)], observer); + }); + + expect(screen.getByText("first controls")).toBeInTheDocument(); + expect(screen.queryByText("first placeholder")).not.toBeInTheDocument(); + expect(observer.unobserve).toHaveBeenCalledWith(shell); + + act(() => { + observer.callback([createEntry(shell, false)], observer); + }); + + expect(screen.getByText("first controls")).toBeInTheDocument(); + }); + + it("shares one observer across multiple shells", () => { + installIntersectionObserver(); + render( + <> + + + , + ); + + expect(observers).toHaveLength(1); + expect(observers[0]?.observe).toHaveBeenCalledTimes(2); + + const [observer] = observers; + const secondShell = screen.getByTestId("second-shell"); + + if (observer === undefined) { + throw new Error("Expected an IntersectionObserver"); + } + + act(() => { + observer.callback([createEntry(secondShell, true)], observer); + }); + + expect(screen.getByText("first placeholder")).toBeInTheDocument(); + expect(screen.getByText("second controls")).toBeInTheDocument(); + }); + + it("unobserves a pending shell and releases the shared observer on unmount", () => { + installIntersectionObserver(); + const { unmount } = render(); + + const [observer] = observers; + const shell = screen.getByTestId("first-shell"); + + if (observer === undefined) { + throw new Error("Expected an IntersectionObserver"); + } + + unmount(); + + expect(observer.unobserve).toHaveBeenCalledWith(shell); + expect(observer.disconnect).toHaveBeenCalledOnce(); + }); + + it("mounts content when IntersectionObserver is unavailable", async () => { + vi.stubGlobal("IntersectionObserver", undefined); + render(); + + expect(await screen.findByText("first controls")).toBeInTheDocument(); + }); +}); diff --git a/airflow-core/src/airflow/ui/src/hooks/useNearViewport.ts b/airflow-core/src/airflow/ui/src/hooks/useNearViewport.ts new file mode 100644 index 0000000000000..f2f142a51f88c --- /dev/null +++ b/airflow-core/src/airflow/ui/src/hooks/useNearViewport.ts @@ -0,0 +1,97 @@ +/*! + * 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 { startTransition, useEffect, useRef, useState, type RefObject } from "react"; + +const NEAR_VIEWPORT_MARGIN = "600px 0px"; + +const listeners = new Map void>(); +let sharedObserver: IntersectionObserver | undefined; + +const releaseObserverWhenIdle = () => { + if (listeners.size === 0) { + sharedObserver?.disconnect(); + sharedObserver = undefined; + } +}; + +const getSharedObserver = (observerConstructor: typeof IntersectionObserver) => { + sharedObserver ??= new observerConstructor( + (entries) => { + entries.forEach((entry) => { + if (!entry.isIntersecting) { + return; + } + + const listener = listeners.get(entry.target); + + if (listener !== undefined) { + listeners.delete(entry.target); + sharedObserver?.unobserve(entry.target); + listener(); + } + }); + releaseObserverWhenIdle(); + }, + { rootMargin: NEAR_VIEWPORT_MARGIN, scrollMargin: NEAR_VIEWPORT_MARGIN }, + ); + + return sharedObserver; +}; + +const observeNearViewport = (element: Element, listener: () => void) => { + const observerConstructor = (globalThis as { IntersectionObserver?: typeof IntersectionObserver }) + .IntersectionObserver; + + if (observerConstructor === undefined) { + listener(); + + return undefined; + } + + listeners.set(element, listener); + getSharedObserver(observerConstructor).observe(element); + + return () => { + listeners.delete(element); + sharedObserver?.unobserve(element); + releaseObserverWhenIdle(); + }; +}; + +export const useNearViewport = (): { + readonly isNearViewport: boolean; + readonly ref: RefObject; +} => { + const ref = useRef(null); + const [isNearViewport, setIsNearViewport] = useState(false); + + useEffect(() => { + const element = ref.current; + + if (isNearViewport || element === null) { + return undefined; + } + + return observeNearViewport(element, () => { + startTransition(() => setIsNearViewport(true)); + }); + }, [isNearViewport]); + + return { isNearViewport, ref }; +}; diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.test.tsx b/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.test.tsx index 95a35080aed4d..877ea8f99f086 100644 --- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.test.tsx +++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.test.tsx @@ -57,10 +57,47 @@ const GMTWrapper = ({ children }: PropsWithChildren) => ( // skeletons rather than StateBadges. Without this, every card would emit // 4 extra "state-badge" testids and break getByTestId assertions in tests // that target the latest-run badge. -const renderCard = (dag: DAGWithLatestDagRunsResponse) => - render(, { - wrapper: GMTWrapper, - }); +const renderCard = (dag: DAGWithLatestDagRunsResponse, deferContent = false) => { + if (!deferContent) { + vi.stubGlobal("IntersectionObserver", undefined); + } + + return render( + , + { + wrapper: GMTWrapper, + }, + ); +}; + +const takeNoObserverRecords = () => []; + +type MockDagCardObserver = { + callback: IntersectionObserverCallback; +} & IntersectionObserver; + +const dagCardObservers: Array = []; + +const MockDagCardIntersectionObserver = function MockDagCardIntersectionObserver( + nextCallback: IntersectionObserverCallback, + options?: IntersectionObserverInit, +): IntersectionObserver { + const observer = { + callback: nextCallback, + disconnect: vi.fn(), + observe: vi.fn(), + root: null, + rootMargin: options?.rootMargin ?? "0px", + scrollMargin: options?.scrollMargin ?? "0px", + takeRecords: vi.fn(takeNoObserverRecords), + thresholds: [0], + unobserve: vi.fn(), + } satisfies MockDagCardObserver; + + dagCardObservers.push(observer); + + return observer; +}; const mockDag = { allowed_run_types: null, @@ -141,9 +178,49 @@ beforeAll(async () => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); describe("DagCard", () => { + it("renders its shell before mounting near-viewport controls", () => { + dagCardObservers.length = 0; + vi.stubGlobal("IntersectionObserver", MockDagCardIntersectionObserver); + renderCard(mockDag, true); + + const card = screen.getByTestId("dag-card"); + + expect(screen.getByTestId("dag-id")).toBeInTheDocument(); + expect(screen.getByTestId("schedule")).toHaveTextContent(mockDag.timetable_summary); + expect(screen.queryByTestId("toggle-pause")).not.toBeInTheDocument(); + expect(screen.queryByTestId("recent-run")).not.toBeInTheDocument(); + + const [observer] = dagCardObservers; + + if (observer === undefined) { + throw new Error("Expected the Dag card to register an IntersectionObserver"); + } + + act(() => { + observer.callback( + [ + { + boundingClientRect: card.getBoundingClientRect(), + intersectionRatio: 1, + intersectionRect: card.getBoundingClientRect(), + isIntersecting: true, + rootBounds: null, + target: card, + time: 0, + }, + ], + observer, + ); + }); + + expect(screen.getByTestId("toggle-pause")).toBeInTheDocument(); + expect(screen.getAllByTestId("recent-run")).toHaveLength(mockDag.latest_dag_runs.length); + }); + it("DagCard should render without tags", () => { renderCard(mockDag); expect(screen.getByText(mockDag.dag_display_name)).toBeInTheDocument(); diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.tsx b/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.tsx index 1d77fb6660a27..b3147c2c3a41b 100644 --- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.tsx +++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.tsx @@ -16,21 +16,18 @@ * specific language governing permissions and limitations * under the License. */ -import { Box, Flex, Grid, GridItem, HStack, Spinner } from "@chakra-ui/react"; +import { Box, Flex, Grid, GridItem, HStack, Spinner, Text } from "@chakra-ui/react"; import { useTranslation } from "react-i18next"; import type { DAGWithLatestDagRunsResponse } from "openapi/requests/types.gen"; -import { DeleteDagButton } from "src/components/DagActions/DeleteDagButton"; -import { FavoriteDagButton } from "src/components/DagActions/FavoriteDagButton"; import DagRunInfo from "src/components/DagRunInfo"; -import { NeedsReviewBadge } from "src/components/NeedsReviewBadge"; import { Stat } from "src/components/Stat"; -import { TogglePause } from "src/components/TogglePause"; -import { TriggerDAGButton } from "src/components/TriggerDag/TriggerDAGButton"; import { RouterLink, Tooltip } from "src/components/ui"; +import { useNearViewport } from "src/hooks/useNearViewport"; import { useConfig } from "src/queries/useConfig"; import { isStatePending, useAutoRefresh } from "src/utils"; +import { DagCardActions } from "./DagCardActions"; import { DagRunStateCounts } from "./DagRunStateCounts"; import { DagTags } from "./DagTags"; import { RecentRuns } from "./RecentRuns"; @@ -47,6 +44,7 @@ export const DagCard = ({ dag, runStateCounts, runStateCountsLoading, stateCount const { t: translate } = useTranslation(["common", "dag"]); const [latestRun] = dag.latest_dag_runs; const multiTeamEnabled = Boolean(useConfig("multi_team")); + const { isNearViewport, ref } = useNearViewport(); const refetchInterval = useAutoRefresh({}); @@ -56,7 +54,9 @@ export const DagCard = ({ dag, runStateCounts, runStateCountsLoading, stateCount borderRadius={8} borderWidth={1} data-testid="dag-card" + minHeight="140px" overflow="hidden" + ref={ref} > @@ -67,17 +67,8 @@ export const DagCard = ({ dag, runStateCounts, runStateCountsLoading, stateCount - - - - - - + + {isNearViewport ? : undefined} - + {isNearViewport ? ( + + ) : ( + {dag.timetable_summary} + )} @@ -144,15 +139,21 @@ export const DagCard = ({ dag, runStateCounts, runStateCountsLoading, stateCount gridRow="1 / 3" justifyContent="flex-end" > - + + {isNearViewport ? : undefined} + - + + {isNearViewport ? ( + + ) : undefined} + diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagCardActions.tsx b/airflow-core/src/airflow/ui/src/pages/DagsList/DagCardActions.tsx new file mode 100644 index 0000000000000..95aa1f2c19980 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagCardActions.tsx @@ -0,0 +1,39 @@ +/*! + * 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 { DAGWithLatestDagRunsResponse } from "openapi/requests/types.gen"; +import { DeleteDagButton } from "src/components/DagActions/DeleteDagButton"; +import { FavoriteDagButton } from "src/components/DagActions/FavoriteDagButton"; +import { NeedsReviewBadge } from "src/components/NeedsReviewBadge"; +import { TogglePause } from "src/components/TogglePause"; +import { TriggerDAGButton } from "src/components/TriggerDag/TriggerDAGButton"; + +export const DagCardActions = ({ dag }: { readonly dag: DAGWithLatestDagRunsResponse }) => ( + <> + + + + + + +); From 5379834f8e3d7e64569ce251540d6c8e15cba482 Mon Sep 17 00:00:00 2001 From: Shivam Rastogi <6463385+shivaam@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:14:43 -0700 Subject: [PATCH 2/3] Preload DAG cards from their scroll root --- .../ui/src/hooks/useNearViewport.test.tsx | 54 ++++++++++- .../airflow/ui/src/hooks/useNearViewport.ts | 91 ++++++++++++++----- .../ui/src/pages/DagsList/DagCard.test.tsx | 84 +++++++++++++---- .../airflow/ui/src/pages/DagsList/DagCard.tsx | 7 +- 4 files changed, 191 insertions(+), 45 deletions(-) diff --git a/airflow-core/src/airflow/ui/src/hooks/useNearViewport.test.tsx b/airflow-core/src/airflow/ui/src/hooks/useNearViewport.test.tsx index 67cba3caffe49..ab72fdebfbfdc 100644 --- a/airflow-core/src/airflow/ui/src/hooks/useNearViewport.test.tsx +++ b/airflow-core/src/airflow/ui/src/hooks/useNearViewport.test.tsx @@ -47,7 +47,7 @@ const MockIntersectionObserver = function MockIntersectionObserver( callback, disconnect: vi.fn(), observe: vi.fn(), - root: null, + root: options?.root ?? null, rootMargin: options?.rootMargin ?? "0px", scrollMargin: options?.scrollMargin ?? "0px", takeRecords: vi.fn(takeNoRecords), @@ -90,7 +90,7 @@ describe("useNearViewport", () => { expect(screen.queryByText("first controls")).not.toBeInTheDocument(); expect(observers).toHaveLength(1); expect(observers[0]?.rootMargin).toBe("600px 0px"); - expect(observers[0]?.scrollMargin).toBe("600px 0px"); + expect(observers[0]?.scrollMargin).toBe("0px"); const [observer] = observers; const shell = screen.getByTestId("first-shell"); @@ -141,6 +141,56 @@ describe("useNearViewport", () => { expect(screen.getByText("second controls")).toBeInTheDocument(); }); + it("uses the nearest scroll container as the preload root", () => { + installIntersectionObserver(); + render( +
{ + if (element !== null) { + Object.defineProperties(element, { + clientHeight: { configurable: true, value: 100 }, + scrollHeight: { configurable: true, value: 1000 }, + }); + } + }} + style={{ overflowY: "auto" }} + > + +
, + ); + + expect(observers).toHaveLength(1); + expect(observers[0]?.root).toBe(screen.getByTestId("scroll-root")); + expect(observers[0]?.rootMargin).toBe("600px 0px"); + }); + + it("skips an overflow wrapper that does not actually scroll", () => { + installIntersectionObserver(); + render( +
{ + if (element !== null) { + Object.defineProperties(element, { + clientHeight: { configurable: true, value: 100 }, + scrollHeight: { configurable: true, value: 1000 }, + }); + } + }} + style={{ overflowY: "auto" }} + > +
+ +
+
, + ); + + expect(observers).toHaveLength(1); + expect(observers[0]?.root).toBe(screen.getByTestId("scroll-root")); + expect(observers[0]?.root).not.toBe(screen.getByTestId("overflow-wrapper")); + }); + it("unobserves a pending shell and releases the shared observer on unmount", () => { installIntersectionObserver(); const { unmount } = render(); diff --git a/airflow-core/src/airflow/ui/src/hooks/useNearViewport.ts b/airflow-core/src/airflow/ui/src/hooks/useNearViewport.ts index f2f142a51f88c..12ff91a0e49e5 100644 --- a/airflow-core/src/airflow/ui/src/hooks/useNearViewport.ts +++ b/airflow-core/src/airflow/ui/src/hooks/useNearViewport.ts @@ -16,22 +16,57 @@ * specific language governing permissions and limitations * under the License. */ -import { startTransition, useEffect, useRef, useState, type RefObject } from "react"; +import { startTransition, useCallback, useEffect, useRef, useState, type RefObject } from "react"; const NEAR_VIEWPORT_MARGIN = "600px 0px"; -const listeners = new Map void>(); -let sharedObserver: IntersectionObserver | undefined; +type ObserverState = { + readonly listeners: Map void>; + readonly observer: IntersectionObserver; +}; + +const observerStates = new Map(); + +const getScrollRoot = (element: Element): Element | null => { + let ancestor = element.parentElement; + let overflowAncestor: Element | null = null; + + while (ancestor !== null) { + const { overflowY } = globalThis.getComputedStyle(ancestor); + + if (/^(?:auto|overlay|scroll)$/u.test(overflowY)) { + overflowAncestor ??= ancestor; + + if (ancestor.scrollHeight > ancestor.clientHeight) { + return ancestor; + } + } + + ancestor = ancestor.parentElement; + } + + return overflowAncestor; +}; + +const releaseObserverWhenIdle = (root: Element | null, state: ObserverState) => { + if (state.listeners.size === 0) { + state.observer.disconnect(); -const releaseObserverWhenIdle = () => { - if (listeners.size === 0) { - sharedObserver?.disconnect(); - sharedObserver = undefined; + if (observerStates.get(root) === state) { + observerStates.delete(root); + } } }; -const getSharedObserver = (observerConstructor: typeof IntersectionObserver) => { - sharedObserver ??= new observerConstructor( +const getSharedObserver = (observerConstructor: typeof IntersectionObserver, root: Element | null) => { + const currentState = observerStates.get(root); + + if (currentState !== undefined) { + return currentState; + } + + const listeners = new Map void>(); + const observer = new observerConstructor( (entries) => { entries.forEach((entry) => { if (!entry.isIntersecting) { @@ -42,44 +77,58 @@ const getSharedObserver = (observerConstructor: typeof IntersectionObserver) => if (listener !== undefined) { listeners.delete(entry.target); - sharedObserver?.unobserve(entry.target); + observer.unobserve(entry.target); listener(); } }); - releaseObserverWhenIdle(); + const observerState = observerStates.get(root); + + if (observerState !== undefined) { + releaseObserverWhenIdle(root, observerState); + } }, - { rootMargin: NEAR_VIEWPORT_MARGIN, scrollMargin: NEAR_VIEWPORT_MARGIN }, + { root, rootMargin: NEAR_VIEWPORT_MARGIN }, ); - return sharedObserver; + const state = { listeners, observer }; + + observerStates.set(root, state); + + return state; }; const observeNearViewport = (element: Element, listener: () => void) => { const observerConstructor = (globalThis as { IntersectionObserver?: typeof IntersectionObserver }) .IntersectionObserver; + // If observation is unavailable, we immediately call the listener. if (observerConstructor === undefined) { listener(); return undefined; } - listeners.set(element, listener); - getSharedObserver(observerConstructor).observe(element); + const root = getScrollRoot(element); + const state = getSharedObserver(observerConstructor, root); + + state.listeners.set(element, listener); + state.observer.observe(element); return () => { - listeners.delete(element); - sharedObserver?.unobserve(element); - releaseObserverWhenIdle(); + state.listeners.delete(element); + state.observer.unobserve(element); + releaseObserverWhenIdle(root, state); }; }; export const useNearViewport = (): { readonly isNearViewport: boolean; readonly ref: RefObject; + readonly showContent: () => void; } => { const ref = useRef(null); const [isNearViewport, setIsNearViewport] = useState(false); + const showContent = useCallback(() => setIsNearViewport(true), []); useEffect(() => { const element = ref.current; @@ -89,9 +138,9 @@ export const useNearViewport = (): { } return observeNearViewport(element, () => { - startTransition(() => setIsNearViewport(true)); + startTransition(showContent); }); - }, [isNearViewport]); + }, [isNearViewport, showContent]); - return { isNearViewport, ref }; + return { isNearViewport, ref, showContent }; }; diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.test.tsx b/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.test.tsx index 877ea8f99f086..04064406576e8 100644 --- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.test.tsx +++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.test.tsx @@ -53,23 +53,6 @@ const GMTWrapper = ({ children }: PropsWithChildren) => ( ); -// Render with the run-state-counts row in its loading state so it renders -// skeletons rather than StateBadges. Without this, every card would emit -// 4 extra "state-badge" testids and break getByTestId assertions in tests -// that target the latest-run badge. -const renderCard = (dag: DAGWithLatestDagRunsResponse, deferContent = false) => { - if (!deferContent) { - vi.stubGlobal("IntersectionObserver", undefined); - } - - return render( - , - { - wrapper: GMTWrapper, - }, - ); -}; - const takeNoObserverRecords = () => []; type MockDagCardObserver = { @@ -86,7 +69,7 @@ const MockDagCardIntersectionObserver = function MockDagCardIntersectionObserver callback: nextCallback, disconnect: vi.fn(), observe: vi.fn(), - root: null, + root: options?.root ?? null, rootMargin: options?.rootMargin ?? "0px", scrollMargin: options?.scrollMargin ?? "0px", takeRecords: vi.fn(takeNoObserverRecords), @@ -99,6 +82,57 @@ const MockDagCardIntersectionObserver = function MockDagCardIntersectionObserver return observer; }; +type CardContentMode = "fallback" | "hydrated" | "pending"; + +// Render with the run-state-counts row in its loading state so it renders +// skeletons rather than StateBadges. Without this, every card would emit +// 4 extra "state-badge" testids and break getByTestId assertions in tests +// that target the latest-run badge. +const renderCard = (dag: DAGWithLatestDagRunsResponse, contentMode: CardContentMode = "hydrated") => { + dagCardObservers.length = 0; + + if (contentMode === "fallback") { + vi.stubGlobal("IntersectionObserver", undefined); + } else { + vi.stubGlobal("IntersectionObserver", MockDagCardIntersectionObserver); + } + + const result = render( + , + { + wrapper: GMTWrapper, + }, + ); + + if (contentMode === "hydrated") { + const [observer] = dagCardObservers; + const card = screen.getByTestId("dag-card"); + + if (observer === undefined) { + throw new Error("Expected the Dag card to register an IntersectionObserver"); + } + + act(() => { + observer.callback( + [ + { + boundingClientRect: card.getBoundingClientRect(), + intersectionRatio: 1, + intersectionRect: card.getBoundingClientRect(), + isIntersecting: true, + rootBounds: null, + target: card, + time: 0, + }, + ], + observer, + ); + }); + } + + return result; +}; + const mockDag = { allowed_run_types: null, asset_expression: null, @@ -185,7 +219,7 @@ describe("DagCard", () => { it("renders its shell before mounting near-viewport controls", () => { dagCardObservers.length = 0; vi.stubGlobal("IntersectionObserver", MockDagCardIntersectionObserver); - renderCard(mockDag, true); + renderCard(mockDag, "pending"); const card = screen.getByTestId("dag-card"); @@ -221,6 +255,18 @@ describe("DagCard", () => { expect(screen.getAllByTestId("recent-run")).toHaveLength(mockDag.latest_dag_runs.length); }); + it("mounts deferred controls when keyboard focus enters the card", () => { + dagCardObservers.length = 0; + vi.stubGlobal("IntersectionObserver", MockDagCardIntersectionObserver); + renderCard(mockDag, "pending"); + + expect(screen.queryByTestId("toggle-pause")).not.toBeInTheDocument(); + + fireEvent.focus(screen.getByTestId("dag-id")); + + expect(screen.getByTestId("toggle-pause")).toBeInTheDocument(); + }); + it("DagCard should render without tags", () => { renderCard(mockDag); expect(screen.getByText(mockDag.dag_display_name)).toBeInTheDocument(); diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.tsx b/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.tsx index b3147c2c3a41b..188b484fe8c06 100644 --- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.tsx +++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.tsx @@ -44,7 +44,7 @@ export const DagCard = ({ dag, runStateCounts, runStateCountsLoading, stateCount const { t: translate } = useTranslation(["common", "dag"]); const [latestRun] = dag.latest_dag_runs; const multiTeamEnabled = Boolean(useConfig("multi_team")); - const { isNearViewport, ref } = useNearViewport(); + const { isNearViewport, ref, showContent } = useNearViewport(); const refetchInterval = useAutoRefresh({}); @@ -55,6 +55,7 @@ export const DagCard = ({ dag, runStateCounts, runStateCountsLoading, stateCount borderWidth={1} data-testid="dag-card" minHeight="140px" + onFocusCapture={showContent} overflow="hidden" ref={ref} > @@ -67,7 +68,7 @@ export const DagCard = ({ dag, runStateCounts, runStateCountsLoading, stateCount - + {isNearViewport ? : undefined} @@ -144,7 +145,7 @@ export const DagCard = ({ dag, runStateCounts, runStateCountsLoading, stateCount - + {isNearViewport ? ( Date: Wed, 22 Jul 2026 10:07:41 -0700 Subject: [PATCH 3/3] Explain DAG card scroll root selection --- airflow-core/src/airflow/ui/src/hooks/useNearViewport.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/airflow-core/src/airflow/ui/src/hooks/useNearViewport.ts b/airflow-core/src/airflow/ui/src/hooks/useNearViewport.ts index 12ff91a0e49e5..64785c6a1dd8b 100644 --- a/airflow-core/src/airflow/ui/src/hooks/useNearViewport.ts +++ b/airflow-core/src/airflow/ui/src/hooks/useNearViewport.ts @@ -31,6 +31,8 @@ const getScrollRoot = (element: Element): Element | null => { let ancestor = element.parentElement; let overflowAncestor: Element | null = null; + // Use the nearest ancestor that actually scrolls, falling back to the nearest + // overflow container when its content currently fits without scrolling. while (ancestor !== null) { const { overflowY } = globalThis.getComputedStyle(ancestor);