Skip to content

Commit 73966b3

Browse files
Fixed brittle scrollToTop functions in Stats and PostAnalytics (#25671)
ref https://linear.app/ghost/issue/BER-2999/ ref #25583 ref #25501 ## Problem - the [recently introduced](#25583) `scrollToTop` functions use a brittle approach of assuming the first element found with an `overflow-y-scroll` class is the main scrollable content area - in the React admin shell we were re-working the overall app layout to accommodate alert bars and reduce the number of scrollable elements, this resulted in the removal of the `overflow-y-scroll` class on the main content area breaking these functions ## Fix - pulled the `getScrollParent` utility out of the posts `virtual-table` components into a re-usable Shade utility (`@tryghost/shade/utils`), and pointed its existing consumers at it - added container refs to both Stats and PostAnalytics layout components so an element is available from which to find the scrollable parent - updated the `scrollToTop` functions to use `getScrollParent` with the container ref so no matter what classes get used we're always scrolling the right parent scroll container
1 parent cab523e commit 73966b3

9 files changed

Lines changed: 59 additions & 43 deletions

File tree

apps/posts/src/components/virtual-table/get-scroll-parent.tsx

Lines changed: 0 additions & 14 deletions
This file was deleted.

apps/posts/src/components/virtual-table/use-infinite-virtual-scroll.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {getScrollParent} from './get-scroll-parent';
1+
import {getScrollParent} from '@tryghost/shade/utils';
22
import {useEffect} from 'react';
33
import {useVirtualizer} from '@tanstack/react-virtual';
44

apps/posts/src/components/virtual-table/use-scroll-restoration.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {getScrollParent} from './get-scroll-parent';
1+
import {getScrollParent} from '@tryghost/shade/utils';
22
import {useEffect, useRef, useState} from 'react';
33
import {useLocation} from '@tryghost/admin-x-framework';
44

apps/posts/src/views/PostAnalytics/Web/web.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@ import StatsFilter from '../components/stats-filter';
88
import {BarChartLoadingIndicator, Card, CardContent, EmptyIndicator, NavbarActions} from '@tryghost/shade/components';
99
import {BaseSourceData, useNavigate, useParams, useTinybirdQuery} from '@tryghost/admin-x-framework';
1010
import {KpiDataItem, getWebKpiValues} from '@src/utils/kpi-helpers';
11-
import {LucideIcon} from '@tryghost/shade/utils';
11+
import {LucideIcon, getScrollParent} from '@tryghost/shade/utils';
1212
import {STATS_RANGES, UNKNOWN_LOCATION_VALUES} from '@src/utils/constants';
1313
import {createFilter} from '@tryghost/shade/patterns';
1414
import {formatQueryDate, getRangeDates, getRangeForStartDate} from '@tryghost/shade/app';
1515
import {getAudienceFromFilterValues, getAudienceQueryParam} from '@src/utils/audience';
1616
import {getPeriodText} from '@src/utils/chart-helpers';
17-
import {useCallback, useEffect, useMemo} from 'react';
17+
import {useCallback, useEffect, useMemo, useRef} from 'react';
1818
import {useFilterParams} from '@src/hooks/use-filter-params';
1919
import {useGlobalData} from '@src/providers/post-analytics-context';
2020

@@ -31,6 +31,7 @@ const Web: React.FC<postAnalyticsProps> = () => {
3131
const navigate = useNavigate();
3232
const {postId} = useParams();
3333
const {statsConfig, isLoading: isConfigLoading, range, data: globalData, post, isPostLoading} = useGlobalData();
34+
const containerRef = useRef<HTMLElement>(null);
3435

3536
// Use URL-synced filter state for bookmarking and sharing
3637
const {filters: analyticsFilters, setFilters: setAnalyticsFilters} = useFilterParams();
@@ -64,7 +65,7 @@ const Web: React.FC<postAnalyticsProps> = () => {
6465

6566
// Scroll to top of the scrollable container
6667
const scrollToTop = useCallback(() => {
67-
const scrollContainer = document.querySelector('.overflow-y-scroll');
68+
const scrollContainer = getScrollParent(containerRef.current);
6869
if (scrollContainer) {
6970
scrollContainer.scrollTo({top: 0, behavior: 'smooth'});
7071
}
@@ -224,7 +225,7 @@ const Web: React.FC<postAnalyticsProps> = () => {
224225
{!hasFilters && <DateRangeSelect />}
225226
</NavbarActions>
226227
</PostAnalyticsHeader>
227-
<PostAnalyticsContent>
228+
<PostAnalyticsContent ref={containerRef}>
228229
{isPageLoading ?
229230
<Card className='size-full' variant='plain'>
230231
<CardContent className='size-full items-center justify-center'>
Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1-
import React from 'react';
1+
import React, {forwardRef} from 'react';
22
import {cn} from '@tryghost/shade/utils';
33

4-
const PostAnalyticsContent: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({children, className, ...props}) => {
5-
return (
6-
<section className={cn('flex gap-6 flex-col py-8 size-full grow', className)} {...props}>
7-
{children}
8-
</section>
9-
);
10-
};
4+
const PostAnalyticsContent = forwardRef<HTMLElement, React.HTMLAttributes<HTMLElement>>(
5+
({children, className, ...props}, ref) => {
6+
return (
7+
<section ref={ref} className={cn('flex gap-6 flex-col py-8 size-full grow', className)} {...props}>
8+
{children}
9+
</section>
10+
);
11+
}
12+
);
13+
14+
PostAnalyticsContent.displayName = 'PostAnalyticsContent';
1115

1216
export default PostAnalyticsContent;

apps/shade/src/lib/ds-utils.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,24 @@ export function debounce<T extends unknown[]>(func: (...args: T) => void, wait:
3636
};
3737
}
3838

39+
// Helper to find scroll parent of element
40+
export function getScrollParent(node: Node | null): HTMLElement | null {
41+
if (!node) {
42+
return null;
43+
}
44+
45+
if (node instanceof HTMLElement) {
46+
const overflowY = window.getComputedStyle(node).overflowY;
47+
const isScrollable = overflowY !== 'visible' && overflowY !== 'hidden';
48+
49+
if (isScrollable && node.scrollHeight >= node.clientHeight) {
50+
return node;
51+
}
52+
}
53+
54+
return getScrollParent(node.parentNode) || document.body;
55+
}
56+
3957
/* Data formatters
4058
/* -------------------------------------------------------------------------- */
4159

apps/shade/src/utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export {useSimplePagination} from './hooks/use-simple-pagination';
88
export {
99
cn,
1010
debounce,
11+
getScrollParent,
1112
kebabToPascalCase,
1213
formatTimestamp,
1314
formatNumber,

apps/stats/src/views/Stats/Web/web.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import DateRangeSelect from '../components/date-range-select';
22
import LocationsCard from '../Locations/components/locations-card';
3-
import React, {useCallback, useMemo} from 'react';
3+
import React, {useCallback, useMemo, useRef} from 'react';
44
import SourcesCard from './components/sources-card';
55
import StatsFilter from '../components/stats-filter';
66
import StatsHeader from '../layout/stats-header';
@@ -13,7 +13,7 @@ import {KpiMetric} from '@src/types/kpi';
1313
import {Navigate, useAppContext, useTinybirdQuery} from '@tryghost/admin-x-framework';
1414
import {STATS_DEFAULT_SOURCE_ICON_URL} from '@src/utils/constants';
1515
import {createFilter} from '@tryghost/shade/patterns';
16-
import {formatDuration, formatNumber, formatPercentage} from '@tryghost/shade/utils';
16+
import {formatDuration, formatNumber, formatPercentage, getScrollParent} from '@tryghost/shade/utils';
1717
import {formatQueryDate, getRangeDates} from '@tryghost/shade/app';
1818
import {getAudienceFromFilterValues, getAudienceQueryParam} from '@src/utils/audience';
1919
import {useFilterParams} from '@hooks/use-filter-params';
@@ -58,6 +58,8 @@ const Web: React.FC = () => {
5858
const {startDate, endDate, timezone} = getRangeDates(range);
5959
const {appSettings} = useAppContext();
6060

61+
const containerRef = useRef<HTMLDivElement>(null);
62+
6163
// Use URL-synced filter state for bookmarking and sharing
6264
const {filters: analyticsFilters, setFilters: setAnalyticsFilters} = useFilterParams();
6365

@@ -73,7 +75,7 @@ const Web: React.FC = () => {
7375

7476
// Scroll to top of the scrollable container
7577
const scrollToTop = useCallback(() => {
76-
const scrollContainer = document.querySelector('.overflow-y-scroll');
78+
const scrollContainer = getScrollParent(containerRef.current);
7779
if (scrollContainer) {
7880
scrollContainer.scrollTo({top: 0, behavior: 'smooth'});
7981
}
@@ -186,7 +188,7 @@ const Web: React.FC = () => {
186188
const hasFilters = analyticsFilters.length > 0;
187189

188190
return (
189-
<StatsLayout>
191+
<StatsLayout ref={containerRef}>
190192
<StatsHeader>
191193
{hasFilters &&
192194
<NavbarActions>
Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
import MainLayout from '@src/components/layout';
2-
import React from 'react';
2+
import React, {forwardRef} from 'react';
33

4-
const StatsLayout: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({children}) => {
5-
return (
6-
<MainLayout>
7-
<div className='grid w-full grow'>
8-
<div className='flex h-full flex-col px-6'>
9-
{children}
4+
const StatsLayout = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
5+
({children}, ref) => {
6+
return (
7+
<MainLayout>
8+
<div ref={ref} className='grid w-full grow'>
9+
<div className='flex h-full flex-col px-6'>
10+
{children}
11+
</div>
1012
</div>
11-
</div>
12-
</MainLayout>
13-
);
14-
};
13+
</MainLayout>
14+
);
15+
}
16+
);
17+
18+
StatsLayout.displayName = 'StatsLayout';
1519

1620
export default StatsLayout;

0 commit comments

Comments
 (0)