Skip to content

Commit f386854

Browse files
committed
Attempt to fix hydration sidebar scroll
1 parent e71dfe5 commit f386854

File tree

2 files changed

+68
-95
lines changed

2 files changed

+68
-95
lines changed

src/components/DeclarationsSidebar.tsx

Lines changed: 67 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, {
1+
import {
22
useCallback,
33
useContext,
44
useEffect,
@@ -43,20 +43,21 @@ function groupByModule(declarations: Declaration[]): ModuleGroup[] {
4343
);
4444
}
4545

46-
const HEADER_HEIGHT = 28;
47-
const HEADER_GAP = 8;
48-
const ITEM_HEIGHT = 28;
46+
const ROW_HEIGHT = 28;
47+
const STATIC_BEFORE = 19;
48+
const STATIC_AFTER = 60;
4949

5050
export const DeclarationsSidebar = ({ onNavigate }: { onNavigate?: () => void }) => {
5151
const { declarations, game } = useContext(DeclarationsContext);
5252
const { filter, setFilter } = useContext(SidebarFilterContext);
5353
const { module: activeModule = "", scope = "" } = useParams();
5454
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
5555
const [hydrated, setHydrated] = useState(false);
56-
const parentRef = useRef<HTMLUListElement>(null);
56+
const parentRef = useRef<HTMLDivElement>(null);
5757
const wrapperRef = useRef<HTMLDivElement>(null);
5858
const activeStickyIndexRef = useRef(0);
5959
const navigatedFromSidebarRef = useRef(false);
60+
const isInitialMount = useRef(true);
6061

6162
const groups = useMemo(() => {
6263
let filtered = declarations;
@@ -67,28 +68,21 @@ export const DeclarationsSidebar = ({ onNavigate }: { onNavigate?: () => void })
6768
return groupByModule(filtered);
6869
}, [declarations, filter]);
6970

70-
const rows = useMemo(() => {
71-
const result: SidebarRow[] = [];
71+
const { rows, stickyIndexes } = useMemo(() => {
72+
const rows: SidebarRow[] = [];
73+
const stickyIndexes: number[] = [];
7274
for (const g of groups) {
73-
result.push({ type: "header", module: g.module, count: g.items.length });
75+
stickyIndexes.push(rows.length);
76+
rows.push({ type: "header", module: g.module, count: g.items.length });
7477
if (!collapsed.has(g.module)) {
7578
for (const d of g.items) {
76-
result.push({ type: "item", declaration: d });
79+
rows.push({ type: "item", declaration: d });
7780
}
7881
}
7982
}
80-
return result;
83+
return { rows, stickyIndexes };
8184
}, [groups, collapsed]);
8285

83-
const stickyIndexes = useMemo(
84-
() =>
85-
rows.reduce<number[]>((acc, row, i) => {
86-
if (row.type === "header") acc.push(i);
87-
return acc;
88-
}, []),
89-
[rows],
90-
);
91-
9286
const rangeExtractor = useCallback(
9387
(range: Range) => {
9488
let active = 0;
@@ -100,9 +94,11 @@ export const DeclarationsSidebar = ({ onNavigate }: { onNavigate?: () => void })
10094
}
10195
activeStickyIndexRef.current = active;
10296

103-
const next = new Set([active, ...defaultRangeExtractor(range)]);
104-
105-
return [...next].sort((a, b) => a - b);
97+
const result = defaultRangeExtractor(range);
98+
if (result[0] > active) {
99+
result.unshift(active);
100+
}
101+
return result;
106102
},
107103
[stickyIndexes],
108104
);
@@ -115,47 +111,40 @@ export const DeclarationsSidebar = ({ onNavigate }: { onNavigate?: () => void })
115111
);
116112
}, [rows, scope, activeModule]);
117113

114+
const [initialOffset] = useState(() => {
115+
const target = activeIndex >= 0 ? activeIndex : 0;
116+
return Math.max(0, target - STATIC_BEFORE) * ROW_HEIGHT;
117+
});
118+
119+
const setParentRef = useCallback(
120+
(el: HTMLDivElement | null) => {
121+
parentRef.current = el;
122+
if (el && initialOffset > 0) {
123+
el.scrollTop = initialOffset;
124+
}
125+
},
126+
[initialOffset],
127+
);
128+
118129
const virtualizer = useVirtualizer({
119130
count: rows.length,
120131
getScrollElement: () => parentRef.current,
121-
estimateSize: (index) => {
122-
if (rows[index].type === "header") {
123-
return HEADER_HEIGHT + (index > 0 ? HEADER_GAP : 0);
124-
}
125-
return ITEM_HEIGHT;
126-
},
132+
estimateSize: () => ROW_HEIGHT,
127133
overscan: 20,
128134
rangeExtractor,
135+
initialOffset,
129136
});
130137

131-
const didHydrationScroll = useRef(false);
132138
useLayoutEffect(() => {
133139
setHydrated(true);
134140
}, []);
135141

136-
const STATIC_COUNT = 50;
137142
const staticRows = useMemo(() => {
138143
if (hydrated) return [];
139-
const center = activeIndex >= 0 ? activeIndex : 0;
140-
const half = Math.floor(STATIC_COUNT / 2);
141-
let start = Math.max(0, center - half);
142-
const end = Math.min(rows.length, start + STATIC_COUNT);
143-
start = Math.max(0, end - STATIC_COUNT);
144-
145-
const slice = rows.slice(start, end);
146-
147-
// If the first row is an item, prepend its group header
148-
if (slice.length > 0 && slice[0].type === "item") {
149-
for (let i = start - 1; i >= 0; i--) {
150-
if (rows[i].type === "header") {
151-
slice.unshift(rows[i]);
152-
break;
153-
}
154-
}
155-
}
156-
157-
return slice;
158-
}, [hydrated, rows, activeIndex]);
144+
const start = initialOffset / ROW_HEIGHT;
145+
const end = Math.min(rows.length, start + STATIC_BEFORE + STATIC_AFTER + 1);
146+
return rows.slice(start, end);
147+
}, [hydrated, rows, initialOffset]);
159148

160149
const toggleModule = useCallback((module: string) => {
161150
setCollapsed((prev) => {
@@ -182,22 +171,17 @@ export const DeclarationsSidebar = ({ onNavigate }: { onNavigate?: () => void })
182171
});
183172
}, [activeModule, scope]);
184173

185-
// Scroll to active item on hydration (before paint)
186-
useLayoutEffect(() => {
187-
if (!didHydrationScroll.current && hydrated && activeIndex >= 0) {
188-
didHydrationScroll.current = true;
189-
virtualizer.scrollToIndex(activeIndex, { align: "center" });
190-
}
191-
}, [hydrated, activeIndex, virtualizer]);
192-
193174
// Scroll to active item on navigation (skip if the click came from the sidebar)
194175
useEffect(() => {
176+
if (isInitialMount.current) {
177+
isInitialMount.current = false;
178+
return;
179+
}
195180
if (!scope || !activeModule) return;
196181
if (navigatedFromSidebarRef.current) {
197182
navigatedFromSidebarRef.current = false;
198183
return;
199184
}
200-
if (!didHydrationScroll.current) return; // handled by layoutEffect above
201185
if (activeIndex >= 0) {
202186
virtualizer.scrollToIndex(activeIndex, { align: "center" });
203187
}
@@ -234,8 +218,8 @@ export const DeclarationsSidebar = ({ onNavigate }: { onNavigate?: () => void })
234218
/>
235219
</SidebarHeader>
236220
{hydrated ? (
237-
<SidebarList ref={parentRef}>
238-
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
221+
<SidebarList ref={setParentRef}>
222+
<SidebarUl style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
239223
{virtualizer.getVirtualItems().map((virtualRow) => {
240224
const row = rows[virtualRow.index];
241225
const isHeader = row.type === "header";
@@ -251,23 +235,16 @@ export const DeclarationsSidebar = ({ onNavigate }: { onNavigate?: () => void })
251235
top: 0,
252236
left: 0,
253237
width: "100%",
254-
height: virtualRow.size,
238+
height: ROW_HEIGHT,
255239
}}
256240
>
257241
{isHeader ? (
258-
<div
259-
style={{
260-
paddingTop: virtualRow.index > 0 && !isActiveSticky ? HEADER_GAP : 0,
261-
background: "inherit",
262-
}}
242+
<SidebarGroupHeader
243+
data-collapsed={collapsed.has(row.module) || undefined}
244+
onClick={() => toggleModule(row.module)}
263245
>
264-
<SidebarGroupHeader
265-
data-collapsed={collapsed.has(row.module) || undefined}
266-
onClick={() => toggleModule(row.module)}
267-
>
268-
{row.module} ({row.count})
269-
</SidebarGroupHeader>
270-
</div>
246+
{row.module} ({row.count})
247+
</SidebarGroupHeader>
271248
) : (
272249
<DeclarationSidebarElement
273250
declaration={row.declaration}
@@ -277,32 +254,25 @@ export const DeclarationsSidebar = ({ onNavigate }: { onNavigate?: () => void })
277254
</li>
278255
);
279256
})}
280-
</div>
257+
</SidebarUl>
281258
</SidebarList>
282259
) : (
283260
<SidebarList>
284-
{staticRows.map((row, i) => {
285-
if (row.type === "header") {
286-
return (
287-
<li
288-
key={`h-${row.module}`}
289-
style={{
290-
height: HEADER_HEIGHT + (i > 0 ? HEADER_GAP : 0),
291-
paddingTop: i > 0 ? HEADER_GAP : 0,
292-
}}
293-
>
261+
<SidebarUl>
262+
{staticRows.map((row) =>
263+
row.type === "header" ? (
264+
<li key={`h-${row.module}`} style={{ height: ROW_HEIGHT }}>
294265
<SidebarGroupHeader>
295266
{row.module} ({row.count})
296267
</SidebarGroupHeader>
297268
</li>
298-
);
299-
}
300-
return (
301-
<li key={`${row.declaration.module}-${row.declaration.name}`}>
302-
<DeclarationSidebarElement declaration={row.declaration} />
303-
</li>
304-
);
305-
})}
269+
) : (
270+
<li key={`${row.declaration.module}-${row.declaration.name}`}>
271+
<DeclarationSidebarElement declaration={row.declaration} />
272+
</li>
273+
),
274+
)}
275+
</SidebarUl>
306276
</SidebarList>
307277
)}
308278
</SidebarWrapper>
@@ -338,9 +308,12 @@ const SidebarSearchInput = styled(SearchInput)`
338308
background: var(--background);
339309
`;
340310

341-
const SidebarList = styled.ul`
311+
const SidebarList = styled.div`
342312
flex: 1;
343313
overflow: auto;
314+
`;
315+
316+
const SidebarUl = styled.ul`
344317
margin: 0;
345318
padding: 0;
346319
list-style: none;

src/components/layout/Sidebar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ export const SidebarGroupHeader = styled.button`
7878
border: none;
7979
width: 100%;
8080
padding: 0 8px;
81-
height: 30px;
81+
height: 28px;
8282
box-sizing: border-box;
8383
font: inherit;
8484
font-size: 14px;

0 commit comments

Comments
 (0)