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 a25667d2fbd Remember the Dag runs limit across tab switches and visits 
(#74289)
a25667d2fbd is described below

commit a25667d2fbd71649749859f1436e84422fa7f4ca
Author: Tom Ron <[email protected]>
AuthorDate: Wed Oct 7 00:14:15 2026 +0300

    Remember the Dag runs limit across tab switches and visits (#74289)
    
    * Remember the Dag runs limit across tab switches and visits
    
    The limit moved to the ?limit= URL param in 3.3.0, but the tab links
    drop search params, so the choice was lost on every tab switch and
    never remembered. Overview still read the old per-Dag localStorage key
    that nothing wrote anymore.
    
    Use the per-Dag localStorage value as the default and let ?limit=
    override it, so the grid and Overview agree again.
    
    closes: #74281
    
    * Keep the saved Dag runs limit when the gantt view caps it for narrow 
screens
    
    The gantt view's width-based cap was written back through setLimit, which
    now persists to localStorage. Opening a Dag on a narrow screen therefore
    overwrote the user's saved limit, and it never recovered on a wider one.
    The cap is now applied to the displayed and fetched limit only, leaving the
    saved preference untouched.
    
    * Share the URL-or-localStorage state logic between Dag runs limit and 
advanced search
    
    useDagRunsLimit duplicated the "URL value wins, otherwise the remembered
    localStorage value" resolution that useAdvancedSearch already implemented,
    so the two could drift apart. Both now build on one hook, as requested in
    review.
    
    * Test that PanelButtons leaves the saved Dag runs limit alone on narrow 
gantt views
    
    The width cap used to be written back through setLimit, silently replacing 
the
    user's saved limit. Nothing guarded against that coming back.
---
 .../src/airflow/ui/src/hooks/useAdvancedSearch.ts  | 37 +++++------
 .../airflow/ui/src/hooks/useDagRunsLimit.test.tsx  | 76 ++++++++++++++++++++++
 .../src/airflow/ui/src/hooks/useDagRunsLimit.ts    | 44 +++++++++++++
 .../airflow/ui/src/hooks/useUrlOrStoredState.ts    | 62 ++++++++++++++++++
 .../ui/src/layouts/Details/DetailsLayout.tsx       | 20 ++++--
 .../ui/src/layouts/Details/PanelButtons.test.tsx   | 70 ++++++++++++++++++++
 .../ui/src/layouts/Details/PanelButtons.tsx        | 52 ++-------------
 .../ui/src/layouts/Details/runLimitConfig.test.ts  | 36 ++++++++++
 .../ui/src/layouts/Details/runLimitConfig.ts       | 43 ++++++++++++
 9 files changed, 372 insertions(+), 68 deletions(-)

diff --git a/airflow-core/src/airflow/ui/src/hooks/useAdvancedSearch.ts 
b/airflow-core/src/airflow/ui/src/hooks/useAdvancedSearch.ts
index d8aa8d72a96..23fff446e27 100644
--- a/airflow-core/src/airflow/ui/src/hooks/useAdvancedSearch.ts
+++ b/airflow-core/src/airflow/ui/src/hooks/useAdvancedSearch.ts
@@ -16,11 +16,9 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { useSearchParams } from "react-router-dom";
-import { useLocalStorage } from "usehooks-ts";
-
 import { advancedSearchKey } from "src/constants/localStorage";
 import { SearchParamsKeys } from "src/constants/searchParams";
+import { useUrlOrStoredState } from "src/hooks/useUrlOrStoredState";
 import { useDefaultMatchAnywhere } from "src/hooks/useUserSettings";
 
 // The "match anywhere" (substring) toggle is mirrored in the URL so a 
filtered search can be shared
@@ -32,28 +30,29 @@ import { useDefaultMatchAnywhere } from 
"src/hooks/useUserSettings";
 // the global "match anywhere by default" setting. Toggling writes the 
explicit on/off entry and
 // localStorage.
 export const useAdvancedSearch = (key: string) => {
-  const [searchParams, setSearchParams] = useSearchParams();
   const [defaultEnabled] = useDefaultMatchAnywhere();
-  const [storedEnabled, setStoredEnabled] = 
useLocalStorage<boolean>(advancedSearchKey(key), defaultEnabled);
+  const [enabled, onToggle] = useUrlOrStoredState<boolean>({
+    defaultValue: defaultEnabled,
+    readParams: (params) => {
+      const urlValues = params.getAll(SearchParamsKeys.ADVANCED_SEARCH);
 
-  const urlValues = searchParams.getAll(SearchParamsKeys.ADVANCED_SEARCH);
-  const enabled = urlValues.includes(key) || (!urlValues.includes(`-${key}`) 
&& storedEnabled);
+      if (urlValues.includes(key)) {
+        return true;
+      }
 
-  const onToggle = (nextEnabled: boolean) => {
-    setSearchParams((previous) => {
-      const next = new URLSearchParams(previous);
-      const retained = next
+      return urlValues.includes(`-${key}`) ? false : undefined;
+    },
+    storageKey: advancedSearchKey(key),
+    writeParams: (params, nextEnabled) => {
+      const retained = params
         .getAll(SearchParamsKeys.ADVANCED_SEARCH)
         .filter((value) => value !== key && value !== `-${key}`);
 
-      next.delete(SearchParamsKeys.ADVANCED_SEARCH);
-      retained.forEach((value) => 
next.append(SearchParamsKeys.ADVANCED_SEARCH, value));
-      next.append(SearchParamsKeys.ADVANCED_SEARCH, nextEnabled ? key : 
`-${key}`);
-
-      return next;
-    });
-    setStoredEnabled(nextEnabled);
-  };
+      params.delete(SearchParamsKeys.ADVANCED_SEARCH);
+      retained.forEach((value) => 
params.append(SearchParamsKeys.ADVANCED_SEARCH, value));
+      params.append(SearchParamsKeys.ADVANCED_SEARCH, nextEnabled ? key : 
`-${key}`);
+    },
+  });
 
   return { enabled, onToggle };
 };
diff --git a/airflow-core/src/airflow/ui/src/hooks/useDagRunsLimit.test.tsx 
b/airflow-core/src/airflow/ui/src/hooks/useDagRunsLimit.test.tsx
new file mode 100644
index 00000000000..0f7dfe134dc
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/hooks/useDagRunsLimit.test.tsx
@@ -0,0 +1,76 @@
+/*!
+ * 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 { PropsWithChildren } from "react";
+
+import { act, renderHook } from "@testing-library/react";
+import { MemoryRouter, useLocation } from "react-router-dom";
+import { beforeEach, describe, expect, it } from "vitest";
+
+import { dagRunsLimitKey } from "src/constants/localStorage";
+
+import { useDagRunsLimit } from "./useDagRunsLimit";
+
+const createWrapper =
+  (initialEntry: string) =>
+  ({ children }: PropsWithChildren) => (
+    <MemoryRouter initialEntries={[initialEntry]}>{children}</MemoryRouter>
+  );
+
+const renderLimit = (dagId: string, initialEntry: string) =>
+  renderHook(() => ({ ...useDagRunsLimit(dagId), search: useLocation().search 
}), {
+    wrapper: createWrapper(initialEntry),
+  });
+
+describe("useDagRunsLimit", () => {
+  beforeEach(() => {
+    localStorage.clear();
+  });
+
+  it("defaults to 10", () => {
+    expect(renderLimit("my_dag", 
"/dags/my_dag").result.current.limit).toBe(10);
+  });
+
+  it("keeps the chosen limit after the URL param is dropped by navigation", () 
=> {
+    const first = renderLimit("my_dag", "/dags/my_dag?limit=50");
+
+    expect(first.result.current.limit).toBe(50);
+
+    act(() => first.result.current.setLimit(50));
+    first.unmount();
+
+    expect(renderLimit("my_dag", 
"/dags/my_dag/runs").result.current.limit).toBe(50);
+  });
+
+  it("stores the limit per Dag and clears the URL param", () => {
+    const { result } = renderLimit("my_dag", 
"/dags/my_dag?limit=25&state=failed");
+
+    act(() => result.current.setLimit(100));
+
+    expect(result.current.limit).toBe(100);
+    expect(result.current.search).toBe("?state=failed");
+    expect(localStorage.getItem(dagRunsLimitKey("my_dag"))).toBe("100");
+    expect(renderLimit("other_dag", 
"/dags/other_dag").result.current.limit).toBe(10);
+  });
+
+  it("lets the URL param override the remembered value", () => {
+    localStorage.setItem(dagRunsLimitKey("my_dag"), "100");
+
+    expect(renderLimit("my_dag", 
"/dags/my_dag?limit=25").result.current.limit).toBe(25);
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/hooks/useDagRunsLimit.ts 
b/airflow-core/src/airflow/ui/src/hooks/useDagRunsLimit.ts
new file mode 100644
index 00000000000..c940c5a0f3c
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/hooks/useDagRunsLimit.ts
@@ -0,0 +1,44 @@
+/*!
+ * 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 { dagRunsLimitKey } from "src/constants/localStorage";
+import { SearchParamsKeys } from "src/constants/searchParams";
+
+import { useUrlOrStoredState } from "./useUrlOrStoredState";
+
+const DEFAULT_LIMIT = 10;
+
+const readLimitParam = (params: URLSearchParams) => {
+  const limitParam = params.get(SearchParamsKeys.LIMIT);
+
+  return limitParam === null ? undefined : Number(limitParam);
+};
+
+// The remembered per-Dag value survives tab switches and revisits; a 
`?limit=` URL param overrides it.
+export const useDagRunsLimit = (dagId: string) => {
+  const [limit, setLimit] = useUrlOrStoredState<number>({
+    defaultValue: DEFAULT_LIMIT,
+    readParams: readLimitParam,
+    replace: true,
+    storageKey: dagRunsLimitKey(dagId),
+    // The stored value takes over once the user picks a limit, so the URL 
doesn't pin the old one.
+    writeParams: (params) => params.delete(SearchParamsKeys.LIMIT),
+  });
+
+  return { limit, setLimit };
+};
diff --git a/airflow-core/src/airflow/ui/src/hooks/useUrlOrStoredState.ts 
b/airflow-core/src/airflow/ui/src/hooks/useUrlOrStoredState.ts
new file mode 100644
index 00000000000..cd47560a7c9
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/hooks/useUrlOrStoredState.ts
@@ -0,0 +1,62 @@
+/*!
+ * 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 { useSearchParams } from "react-router-dom";
+import { useLocalStorage } from "usehooks-ts";
+
+type Options<T> = {
+  defaultValue: T;
+  // Value carried by the URL, or `undefined` when the URL doesn't specify one.
+  readParams: (params: URLSearchParams) => T | undefined;
+  replace?: boolean;
+  storageKey: string;
+  // Mutates `params` so it carries (or drops) `value`.
+  writeParams: (params: URLSearchParams, value: T) => void;
+};
+
+// A value mirrored between the URL and localStorage. An explicit URL value 
wins so a shared link
+// reproduces the sender's choice, otherwise the remembered localStorage value 
applies. Setting
+// writes both, with `writeParams` deciding what (if anything) the URL keeps.
+export const useUrlOrStoredState = <T>({
+  defaultValue,
+  readParams,
+  replace,
+  storageKey,
+  writeParams,
+}: Options<T>) => {
+  const [searchParams, setSearchParams] = useSearchParams();
+  const [storedValue, setStoredValue] = useLocalStorage<T>(storageKey, 
defaultValue);
+
+  const value = readParams(searchParams) ?? storedValue;
+
+  const setValue = (nextValue: T) => {
+    setStoredValue(nextValue);
+    setSearchParams(
+      (previous) => {
+        const next = new URLSearchParams(previous);
+
+        writeParams(next, nextValue);
+
+        return next;
+      },
+      { replace },
+    );
+  };
+
+  return [value, setValue] as const;
+};
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx
index b290ab4a3b1..60d9a64f53c 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx
@@ -49,8 +49,9 @@ import { DEFAULT_DAG_VIEW_KEY } from 
"src/constants/localStorage";
 import { SearchParamsKeys } from "src/constants/searchParams";
 import { VersionIndicatorOptions } from 
"src/constants/showVersionIndicatorOptions";
 import { GroupsProvider } from "src/context/groups";
+import { useDagRunsLimit } from "src/hooks/useDagRunsLimit";
 import { useGridRuns } from "src/queries/useGridRuns.ts";
-import { formatNumber, useAutoRefresh } from "src/utils";
+import { formatNumber, useAutoRefresh, useContainerWidth } from "src/utils";
 
 import { DagBreadcrumb } from "./DagBreadcrumb";
 import { Gantt } from "./Gantt/Gantt";
@@ -59,6 +60,7 @@ import { Grid } from "./Grid";
 import { useGridCrosshairHover } from "./Grid/useGridCrosshairHover";
 import { NavTabs, type NavTab } from "./NavTabs";
 import { PanelButtons } from "./PanelButtons";
+import { getEffectiveLimit } from "./runLimitConfig";
 
 // Shared scroll container for the grid + gantt in the combined view.
 const SharedScrollBox = ({
@@ -104,7 +106,11 @@ export const DetailsLayout = ({ children, error, 
isLoading, outletContext, tabs
     undefined,
     { enabled: dag?.scheduling_state === "active" },
   );
+  const { limit: storedLimit, setLimit } = useDagRunsLimit(dagId);
   const [dagView, setDagView] = useLocalStorage<DagView>(DEFAULT_DAG_VIEW_KEY, 
"grid");
+  const panelButtonsRef = useRef<HTMLDivElement>(null);
+  const panelButtonsWidth = useContainerWidth(panelButtonsRef);
+  const limit = getEffectiveLimit(storedLimit, panelButtonsWidth, dagView === 
"gantt");
   const panelGroupRef = useGroupRef();
   // Root for the delegated grid/gantt crosshair-hover handler (covers both the
   // grid and the gantt so their shared row highlight stays in sync, with no
@@ -141,7 +147,6 @@ export const DetailsLayout = ({ children, error, isLoading, 
outletContext, tabs
   };
 
   // --- Read state from URL ---
-  const limit = Number(searchParams.get(SearchParamsKeys.LIMIT) ?? "10");
   const runAfterGte = searchParams.get(SearchParamsKeys.RUN_AFTER_GTE) ?? 
undefined;
   const runAfterLte = searchParams.get(SearchParamsKeys.RUN_AFTER_LTE) ?? 
undefined;
   const runTypeFilter = (searchParams.get(SearchParamsKeys.RUN_TYPE) as 
DagRunType | null) ?? undefined;
@@ -150,7 +155,6 @@ export const DetailsLayout = ({ children, error, isLoading, 
outletContext, tabs
   const dagRunStateFilter = (searchParams.get(SearchParamsKeys.STATE) as 
DagRunState | null) ?? undefined;
 
   // --- Setters that write back to URL ---
-  const setLimit = (value: number) => setParam(SearchParamsKeys.LIMIT, 
String(value));
   // Only LTE is needed directly: ceiling logic and jump-to-latest both touch 
it.
   // GTE and the filter params (state, run_type, triggering_user) are managed 
by GridFilters/FilterBar.
   const setRunAfterLte = (value: string | undefined) => 
setParam(SearchParamsKeys.RUN_AFTER_LTE, value);
@@ -312,8 +316,16 @@ export const DetailsLayout = ({ children, error, 
isLoading, outletContext, tabs
                 overflow="hidden"
                 position="relative"
               >
-                <Box left={0} p={2} position={dagView === "graph" ? "absolute" 
: undefined} right={0} top={0}>
+                <Box
+                  left={0}
+                  p={2}
+                  position={dagView === "graph" ? "absolute" : undefined}
+                  ref={panelButtonsRef}
+                  right={0}
+                  top={0}
+                >
                   <PanelButtons
+                    containerWidth={panelButtonsWidth}
                     dagView={dagView}
                     limit={limit}
                     panelGroupRef={panelGroupRef}
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.test.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.test.tsx
new file mode 100644
index 00000000000..d5f1ed042e7
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.test.tsx
@@ -0,0 +1,70 @@
+/*!
+ * 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 "@testing-library/jest-dom";
+import { cleanup, render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { VersionIndicatorOptions } from 
"src/constants/showVersionIndicatorOptions";
+import { BaseWrapper } from "src/utils/Wrapper";
+
+import { PanelButtons } from "./PanelButtons";
+
+// The options under test live in PanelButtons itself; its children fetch data 
or need a flow canvas.
+vi.mock("@xyflow/react", () => ({ useReactFlow: () => ({ fitView: vi.fn() }) 
}));
+vi.mock("src/components/DagVersionSelect", () => ({ DagVersionSelect: () => 
null }));
+vi.mock("src/components/Graph/DirectionDropdown", () => ({ DirectionDropdown: 
() => null }));
+vi.mock("src/components/GraphTaskFilters", () => ({ GraphTaskFilters: () => 
null }));
+vi.mock("./DagRunSelect", () => ({ DagRunSelect: () => null }));
+vi.mock("./Grid/RunTypeLegend", () => ({ RunTypeLegend: () => null }));
+vi.mock("./GridFilters", () => ({ GridFilters: () => null }));
+vi.mock("./TaskStreamFilter", () => ({ TaskStreamFilter: () => null }));
+vi.mock("./ToggleGroups", () => ({ ToggleGroups: () => null }));
+vi.mock("./VersionIndicatorSelect", () => ({ VersionIndicatorSelect: () => 
null }));
+
+afterEach(() => cleanup());
+
+describe("PanelButtons", () => {
+  it("leaves the saved limit alone when it exceeds what the gantt width 
allows", () => {
+    const setLimit = vi.fn();
+
+    render(
+      <PanelButtons
+        containerWidth={300}
+        dagView="gantt"
+        limit={50}
+        panelGroupRef={{ current: null }}
+        setDagView={vi.fn()}
+        setLimit={setLimit}
+        setShowVersionIndicatorMode={vi.fn()}
+        showVersionIndicatorMode={VersionIndicatorOptions.ALL}
+      />,
+      {
+        wrapper: ({ children }) => (
+          <BaseWrapper>
+            <MemoryRouter>{children}</MemoryRouter>
+          </BaseWrapper>
+        ),
+      },
+    );
+
+    expect(screen.getByRole("button", { name: /options/iu 
})).toBeInTheDocument();
+    expect(setLimit).not.toHaveBeenCalled();
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx
index cb9dc2f4891..dd990c31809 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx
@@ -16,18 +16,9 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { type Dispatch, type RefObject, type SetStateAction, useEffect, useRef 
} from "react";
+import type { Dispatch, RefObject, SetStateAction } from "react";
 
-import {
-  Box,
-  createListCollection,
-  Flex,
-  Popover,
-  Portal,
-  Select,
-  type SelectValueChangeDetails,
-  VStack,
-} from "@chakra-ui/react";
+import { Box, Flex, Popover, Portal, Select, type SelectValueChangeDetails, 
VStack } from "@chakra-ui/react";
 import { useReactFlow } from "@xyflow/react";
 import { useTranslation } from "react-i18next";
 import { FiGrid } from "react-icons/fi";
@@ -54,7 +45,6 @@ import { SHOW_ALL_DEPENDENCIES_KEY } from 
"src/constants/localStorage";
 import type { VersionIndicatorOptions } from 
"src/constants/showVersionIndicatorOptions";
 import { SHORTCUTS } from "src/context/keyboardShortcuts";
 import { useShortcut } from "src/hooks/useShortcut";
-import { useContainerWidth } from "src/utils/useContainerWidth";
 
 import { DagRunSelect } from "./DagRunSelect";
 import { RunTypeLegend } from "./Grid/RunTypeLegend";
@@ -62,8 +52,10 @@ import { GridFilters } from "./GridFilters";
 import { TaskStreamFilter } from "./TaskStreamFilter";
 import { ToggleGroups } from "./ToggleGroups";
 import { VersionIndicatorSelect } from "./VersionIndicatorSelect";
+import { getWidthBasedConfig } from "./runLimitConfig";
 
 type Props = {
+  readonly containerWidth: number;
   readonly dagView: DagView;
   readonly limit: number;
   readonly panelGroupRef: RefObject<GroupImperativeHandle | null>;
@@ -73,26 +65,6 @@ type Props = {
   readonly showVersionIndicatorMode: VersionIndicatorOptions;
 };
 
-const getWidthBasedConfig = (width: number, enableResponsiveOptions: boolean) 
=> {
-  const breakpoints = enableResponsiveOptions
-    ? [
-        { limit: 100, min: 1600, options: ["1", "5", "10", "25", "50"] }, // 
xl: extra large screens
-        { limit: 25, min: 1024, options: ["1", "5", "10", "25"] }, // lg: 
large screens
-        { limit: 10, min: 384, options: ["1", "5", "10"] }, // md: medium 
screens
-        { limit: 5, min: 0, options: ["1", "5"] }, // sm: small screens and 
below
-      ]
-    : [{ limit: 5, min: 0, options: ["1", "5", "10", "25", "50"] }];
-
-  const config = breakpoints.find(({ min }) => width >= min) ?? 
breakpoints[breakpoints.length - 1];
-
-  return {
-    displayRunOptions: createListCollection({
-      items: config?.options.map((value) => ({ label: value, value })) ?? [],
-    }),
-    limit: config?.limit ?? 5,
-  };
-};
-
 /**
  * The options popover's trigger. Tooltip and popover each need their own 
element: both set an `id` on
  * whatever they wrap and zag resolves a trigger by id, so sharing one element 
leaves the loser unable
@@ -111,6 +83,7 @@ const OptionsTrigger = ({ label }: { readonly label: string 
}) => (
 );
 
 export const PanelButtons = ({
+  containerWidth,
   dagView,
   limit,
   panelGroupRef,
@@ -127,8 +100,6 @@ export const PanelButtons = ({
     SHOW_ALL_DEPENDENCIES_KEY,
     false,
   );
-  const containerRef = useRef<HTMLDivElement>(null);
-  const containerWidth = useContainerWidth(containerRef);
   const handleLimitChange = (event: SelectValueChangeDetails<{ label: string; 
value: Array<string> }>) => {
     const runLimit = Number(event.value[0]);
 
@@ -137,16 +108,7 @@ export const PanelButtons = ({
 
   const enableResponsiveOptions = dagView === "gantt";
 
-  const { displayRunOptions, limit: defaultLimit } = getWidthBasedConfig(
-    containerWidth,
-    enableResponsiveOptions,
-  );
-
-  useEffect(() => {
-    if (enableResponsiveOptions && limit > defaultLimit) {
-      setLimit(defaultLimit);
-    }
-  }, [defaultLimit, enableResponsiveOptions, limit, setLimit]);
+  const { displayRunOptions } = getWidthBasedConfig(containerWidth, 
enableResponsiveOptions);
 
   const handleFocus = (view: string) => {
     if (panelGroupRef.current) {
@@ -207,7 +169,7 @@ export const PanelButtons = ({
   });
 
   return (
-    <Box position="relative" ref={containerRef} width="100%" zIndex={1}>
+    <Box position="relative" width="100%" zIndex={1}>
       <Flex justifyContent="space-between">
         <ButtonGroupToggle
           bg="bg"
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/runLimitConfig.test.ts 
b/airflow-core/src/airflow/ui/src/layouts/Details/runLimitConfig.test.ts
new file mode 100644
index 00000000000..56121c437d2
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/runLimitConfig.test.ts
@@ -0,0 +1,36 @@
+/*!
+ * 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 { describe, expect, it } from "vitest";
+
+import { getEffectiveLimit } from "./runLimitConfig";
+
+describe("getEffectiveLimit", () => {
+  it.each([
+    { expected: 5, limit: 50, width: 300 },
+    { expected: 25, limit: 50, width: 1200 },
+    { expected: 50, limit: 50, width: 1800 },
+    { expected: 5, limit: 5, width: 1800 },
+  ])("caps $limit at $expected for a $width px wide gantt", ({ expected, 
limit, width }) => {
+    expect(getEffectiveLimit(limit, width, true)).toBe(expected);
+  });
+
+  it("leaves the limit alone when responsive options are off", () => {
+    expect(getEffectiveLimit(50, 300, false)).toBe(50);
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/runLimitConfig.ts 
b/airflow-core/src/airflow/ui/src/layouts/Details/runLimitConfig.ts
new file mode 100644
index 00000000000..037153a679e
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/runLimitConfig.ts
@@ -0,0 +1,43 @@
+/*!
+ * 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 { createListCollection } from "@chakra-ui/react";
+
+export const getWidthBasedConfig = (width: number, enableResponsiveOptions: 
boolean) => {
+  const breakpoints = enableResponsiveOptions
+    ? [
+        { limit: 100, min: 1600, options: ["1", "5", "10", "25", "50"] }, // 
xl: extra large screens
+        { limit: 25, min: 1024, options: ["1", "5", "10", "25"] }, // lg: 
large screens
+        { limit: 10, min: 384, options: ["1", "5", "10"] }, // md: medium 
screens
+        { limit: 5, min: 0, options: ["1", "5"] }, // sm: small screens and 
below
+      ]
+    : [{ limit: 5, min: 0, options: ["1", "5", "10", "25", "50"] }];
+
+  const config = breakpoints.find(({ min }) => width >= min) ?? 
breakpoints[breakpoints.length - 1];
+
+  return {
+    displayRunOptions: createListCollection({
+      items: config?.options.map((value) => ({ label: value, value })) ?? [],
+    }),
+    limit: config?.limit ?? 5,
+  };
+};
+
+// The stored preference is never overwritten by this cap, so it comes back on 
a wider screen.
+export const getEffectiveLimit = (limit: number, width: number, 
enableResponsiveOptions: boolean) =>
+  enableResponsiveOptions ? Math.min(limit, getWidthBasedConfig(width, 
true).limit) : limit;

Reply via email to