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..ab72fdebfbfdc --- /dev/null +++ b/airflow-core/src/airflow/ui/src/hooks/useNearViewport.test.tsx @@ -0,0 +1,217 @@ +/*! + * 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: options?.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("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("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(); + + 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..64785c6a1dd8b --- /dev/null +++ b/airflow-core/src/airflow/ui/src/hooks/useNearViewport.ts @@ -0,0 +1,148 @@ +/*! + * 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, useCallback, useEffect, useRef, useState, type RefObject } from "react"; + +const NEAR_VIEWPORT_MARGIN = "600px 0px"; + +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; + + // 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); + + 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(); + + if (observerStates.get(root) === state) { + observerStates.delete(root); + } + } +}; + +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) { + return; + } + + const listener = listeners.get(entry.target); + + if (listener !== undefined) { + listeners.delete(entry.target); + observer.unobserve(entry.target); + listener(); + } + }); + const observerState = observerStates.get(root); + + if (observerState !== undefined) { + releaseObserverWhenIdle(root, observerState); + } + }, + { root, rootMargin: NEAR_VIEWPORT_MARGIN }, + ); + + 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; + } + + const root = getScrollRoot(element); + const state = getSharedObserver(observerConstructor, root); + + state.listeners.set(element, listener); + state.observer.observe(element); + + return () => { + 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; + + if (isNearViewport || element === null) { + return undefined; + } + + return observeNearViewport(element, () => { + startTransition(showContent); + }); + }, [isNearViewport, showContent]); + + 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 95a35080aed4d..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,14 +53,85 @@ const GMTWrapper = ({ children }: PropsWithChildren) => ( ); +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: options?.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; +}; + +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) => - render(, { - wrapper: GMTWrapper, - }); +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, @@ -141,9 +212,61 @@ 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, "pending"); + + 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("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 1d77fb6660a27..188b484fe8c06 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, showContent } = useNearViewport(); const refetchInterval = useAutoRefresh({}); @@ -56,7 +54,10 @@ export const DagCard = ({ dag, runStateCounts, runStateCountsLoading, stateCount borderRadius={8} borderWidth={1} data-testid="dag-card" + minHeight="140px" + onFocusCapture={showContent} overflow="hidden" + ref={ref} > @@ -67,17 +68,8 @@ export const DagCard = ({ dag, runStateCounts, runStateCountsLoading, stateCount - - - - - - + + {isNearViewport ? : undefined} - + {isNearViewport ? ( + + ) : ( + {dag.timetable_summary} + )} @@ -144,15 +140,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 }) => ( + <> + + + + + + +);