Skip to content

Keep Grid scroll position when opening a task. - #70536

Open
Ei-Sandi wants to merge 1 commit into
apache:mainfrom
Ei-Sandi:grid-scroll-restoration
Open

Keep Grid scroll position when opening a task.#70536
Ei-Sandi wants to merge 1 commit into
apache:mainfrom
Ei-Sandi:grid-scroll-restoration

Conversation

@Ei-Sandi

@Ei-Sandi Ei-Sandi commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary

In the Dag details view, the Grid on the left keeps its own vertical scroll position. Clicking a task that is scrolled below the fold currently snaps the Grid back to the top the first time you do it — losing your place. This PR keeps the scroll position across that navigation, while still resetting to the top when you leave the Dag entirely and come back.

Problem

On a Dag with enough tasks to overflow the Grid, scroll down and click a task that is off the top of the viewport. The list jumps back to scrollTop = 0 (the first task) even though the layout looks unchanged. Clicking a second task (task → task) does not reset the scroll — it stays put. So the reset happens exactly once, on the first click, and it gets more painful the longer the list is (e.g. when task groups are expanded).

Screenrecords

Original

2026-07-27.15-34-28.mp4

Current

2026-07-27.16-06-37.mp4

Root cause

The Grid lives inside DetailsLayout, and every top-level page renders its own DetailsLayout:

  • dags/:dagId<Dag>
  • dags/:dagId/tasks/:taskId<Task>
  • dags/:dagId/runs/:runId<Run>
  • dags/:dagId/runs/:runId/tasks/:taskId<TaskInstance>
  • <GroupTaskInstance>, <MappedTaskInstance>

These are sibling routes in router.tsx, not nested under a shared layout.

  • First click (<Dag><Task>): the matched route element changes, so React unmounts <Dag> (destroying its DetailsLayout, its Grid, and the scroll container DOM node) and mounts a fresh <Task>. The new scroll container starts at scrollTop = 0, and useVirtualizer reads 0 on mount and renders the top rows.
  • Subsequent clicks (<Task><Task>): the matched element type is unchanged; only the :taskId param updates. React keeps the instance and its DOM, so the scroll position survives.

That asymmetry — cross-element on the first hop, same-element afterwards — is why it resets exactly once. It is not task-group specific; groups just make the list longer so more scroll is lost.

Solution (this PR)

Remember the Grid's scrollTop across the remount and restore it, scoped to a single Dag visit.

  • Persist + restoreuseGridScrollRestoration saves scrollTop (in a module-scoped Map keyed by dagId, which survives the remount) on scroll, and restores it once, after the rows exist, in a useLayoutEffect (before
    paint, no flash). Covers both the plain-Grid and the shared Grid + Gantt scroll containers.
  • Reset on leaveuseResetGridScrollOnLeave (called once from the always-mounted BaseLayout) clears the store whenever the route is not a Dag detail path (/dags/<id>…). So scroll is kept only within a Dag; leaving to
    the Dags list / home / assets and returning starts at the top.
Navigation Scroll
Dag → Task → Run → Task Instance (same dagId) preserved
Dag → Dags list / home / assets → back to Dag reset to top
First visit to a Dag starts at top

Files: useGridScrollRestoration.ts (+ test), Grid.tsx (wire-up), and one line in BaseLayout.tsx (reset-on-leave). Routing and the panel layout are untouched.

Alternative considered (not taken)

Make DetailsLayout a single shared parent route so the Grid mounts once and persists across Dag ↔ Run ↔ Task ↔ TaskInstance (the "correct" React Router structure), with per-page content flowing through the existing <Outlet/>.

Rejected as disproportionate: it is a large refactor with real regression risk.

Testing

New unit tests cover:

  • Persist/restore: save-on-scroll, restore-once-rows-exist, deferred restore when rows arrive later, restore fires only once (does not clobber later user scrolling), no-op when there is no saved offset / a zero offset / a missing scroll element, and listener cleanup on unmount.
  • Reset-on-leave: isDagDetailsPath for detail vs non-detail paths, clearGridScrollOffsets drops every entry, and the hook clears on a non-detail path but keeps offsets while inside a Dag (via MemoryRouter).

Was generative AI tooling used to co-author this PR?
  • Yes (please specify the tool below)
    Co-authored-by: [Claude Opus 5] following the guidelines

  • Read the Pull Request Guidelines for more information. Note: commit author/co-author name and email in commits become permanently public when merged.
  • For fundamental code changes, an Airflow Improvement Proposal (AIP) is needed.
  • When adding dependency, check compliance with the ASF 3rd Party License Policy.
  • For significant user-facing changes create newsfragment: {pr_number}.significant.rst, in airflow-core/newsfragments. You can add this file in a follow-up commit after the PR is created so you know the PR number.

@boring-cyborg boring-cyborg Bot added the area:UI Related to UI/UX. For Frontend Developers. label Jul 27, 2026
@potiuk potiuk added the ready for maintainer review Set after triaging when all criteria pass. label Jul 28, 2026
@Ei-Sandi
Ei-Sandi force-pushed the grid-scroll-restoration branch 2 times, most recently from 163c64a to f6c31aa Compare July 29, 2026 00:33
@Ei-Sandi
Ei-Sandi force-pushed the grid-scroll-restoration branch from f6c31aa to 7fbf2eb Compare July 29, 2026 10:18
@bbovenzi bbovenzi added this to the Airflow 3.3.1 milestone Jul 29, 2026
@bbovenzi bbovenzi added the backport-to-v3-3-test Backport to v3-3-test label Jul 29, 2026

@bbovenzi bbovenzi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Actually I think the single shared parent is an interesting route to go. It could help further reduce re-renders.

But let's not store a scrollTop but instead only try to scroll to a task when there is one selected with something like rowVirtualizer.scrollToIndex(). I think we already have the taskId+runId exist as element ids. Then we have no additional state and its possible to deep-link. Also, the scrollTop approach would easily lose sync with task groups expanding/collapsing.

Comment on lines +31 to +34
const DAG_DETAILS_PATH = /^\/dags\/(?<dagId>[^/]+)/u;

export const extractDagIdFromPath = (pathname: string): string | undefined =>
DAG_DETAILS_PATH.exec(pathname)?.groups?.dagId;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can just call const { dagId } = useParams();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bbovenzi Hi, I looked into the option, rowVirtualizer.scrollToIndex() won't work as we have to choose start, center, end or auto and we cannot get back the exact position for the task as when we clicked.

Is there any alternative would you like to suggest ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:UI Related to UI/UX. For Frontend Developers. backport-to-v3-3-test Backport to v3-3-test ready for maintainer review Set after triaging when all criteria pass.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants