From dbbc4006f408f989f31bcb96e7b4551dda47f7a0 Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Thu, 25 Dec 2025 19:50:54 +1100 Subject: [PATCH 1/2] fix: Restore infinite scroll functionality in climb list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix scroll container not found during SSR hydration by using a state-based context with callback ref pattern - Default hasMoreResults to true before SWR fetches data - Catch getLogbook errors to prevent unhandled promise rejections - Pass DOM element reference to InfiniteScroll instead of string ID 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../[size_id]/[set_ids]/[angle]/layout.tsx | 18 ++----- .../app/components/board-page/climbs-list.tsx | 37 +++++++++++---- .../board-page/scrollable-content.tsx | 47 +++++++++++++++++++ .../hooks/use-queue-data-fetching.tsx | 11 +++-- 4 files changed, 87 insertions(+), 26 deletions(-) create mode 100644 packages/web/app/components/board-page/scrollable-content.tsx diff --git a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/layout.tsx b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/layout.tsx index 9c84c52b..40baaf80 100644 --- a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/layout.tsx +++ b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/layout.tsx @@ -5,7 +5,6 @@ import { ParsedBoardRouteParameters, BoardRouteParameters, BoardDetails } from ' import { parseBoardRouteParams, constructClimbListWithSlugs } from '@/app/lib/url-utils'; import { parseBoardRouteParamsWithSlugs } from '@/app/lib/url-utils.server'; import { permanentRedirect } from 'next/navigation'; -import { Content } from 'antd/es/layout/layout'; import QueueControlBar from '@/app/components/queue-control/queue-control-bar'; import { getBoardDetails } from '@/app/lib/data/queries'; import BoardSeshHeader from '@/app/components/board-page/header'; @@ -14,6 +13,7 @@ import { ConnectionSettingsProvider } from '@/app/components/connection-manager/ import { PartyProvider } from '@/app/components/party-manager/party-context'; import PartyProfileWrapper from '@/app/components/party-manager/party-profile-wrapper'; import { Metadata } from 'next'; +import { ScrollableContent } from '@/app/components/board-page/scrollable-content'; /** * Generates a user-friendly page title from board details. @@ -139,21 +139,9 @@ export default async function BoardLayout(props: PropsWithChildren - + {children} - + diff --git a/packages/web/app/components/board-page/climbs-list.tsx b/packages/web/app/components/board-page/climbs-list.tsx index af790f53..d86fa8d9 100644 --- a/packages/web/app/components/board-page/climbs-list.tsx +++ b/packages/web/app/components/board-page/climbs-list.tsx @@ -10,6 +10,7 @@ import ClimbCard from '../climb-card/climb-card'; import { useEffect, useRef } from 'react'; import { PlusCircleOutlined, FireOutlined } from '@ant-design/icons'; import { useSearchParams } from 'next/navigation'; +import { useScrollContainer } from './scrollable-content'; type ClimbsListProps = ParsedBoardRouteParameters & { boardDetails: BoardDetails; @@ -48,6 +49,9 @@ const ClimbsList = ({ boardDetails, initialClimbs }: ClimbsListProps) => { const searchParams = useSearchParams(); const page = searchParams.get('page'); + // Get scroll container ref from context - needed for InfiniteScroll to work properly + const scrollContainer = useScrollContainer(); + // Queue Context provider uses SWR infinite to fetch results, which can only happen clientside. // That data equals null at the start, so when its null we use the initialClimbs array which we // fill on the server side in the page component. This way the user never sees a loading state for @@ -117,14 +121,32 @@ const ClimbsList = ({ boardDetails, initialClimbs }: ClimbsListProps) => { }, []); useEffect(() => { - if (page === '0' && hasDoneFirstFetch && isFetchingClimbs) { - const scrollContainer = document.getElementById('content-for-scrollable'); - if (scrollContainer) { - scrollContainer.scrollTo({ top: 0, behavior: 'instant' }); - } + if (page === '0' && hasDoneFirstFetch && isFetchingClimbs && scrollContainer) { + scrollContainer.scrollTo({ top: 0, behavior: 'instant' }); climbsRefs.current = {}; } - }, [page, hasDoneFirstFetch, isFetchingClimbs]); // Depend on the page query parameter + }, [page, hasDoneFirstFetch, isFetchingClimbs, scrollContainer]); + + // Don't render InfiniteScroll until we have the scroll container ref + if (!scrollContainer) { + return ( + + {initialClimbs.map((climb) => ( + + { + updateHash(climb.uuid); + setCurrentClimb(climb); + }} + /> + + ))} + + ); + } return ( { hasMore={hasMoreResults} loader={} endMessage={
No more climbs 🤐
} - // Probably not how this should be done in a React app, but it works and I ain't no CSS-wizard - scrollableTarget="content-for-scrollable" + scrollableTarget={scrollContainer} style={{ paddingTop: '5px' }} > diff --git a/packages/web/app/components/board-page/scrollable-content.tsx b/packages/web/app/components/board-page/scrollable-content.tsx new file mode 100644 index 00000000..a679bf86 --- /dev/null +++ b/packages/web/app/components/board-page/scrollable-content.tsx @@ -0,0 +1,47 @@ +'use client'; + +import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react'; +import { Content } from 'antd/es/layout/layout'; + +type ScrollContainerContextType = HTMLDivElement | null; + +const ScrollContainerContext = createContext(null); + +export function useScrollContainer(): HTMLDivElement | null { + return useContext(ScrollContainerContext); +} + +interface ScrollableContentProps { + children: ReactNode; +} + +export function ScrollableContent({ children }: ScrollableContentProps) { + const [scrollContainer, setScrollContainer] = useState(null); + + const scrollContainerRef = useCallback((node: HTMLDivElement | null) => { + if (node !== null) { + setScrollContainer(node); + } + }, []); + + return ( + + + {children} + + + ); +} diff --git a/packages/web/app/components/queue-control/hooks/use-queue-data-fetching.tsx b/packages/web/app/components/queue-control/hooks/use-queue-data-fetching.tsx index 568053c0..4485651a 100644 --- a/packages/web/app/components/queue-control/hooks/use-queue-data-fetching.tsx +++ b/packages/web/app/components/queue-control/hooks/use-queue-data-fetching.tsx @@ -61,7 +61,11 @@ export const useQueueDataFetching = ({ initialSize: searchParams.page ? searchParams.page + 1 : 1, }); - const hasMoreResults = data && data[0] && size * PAGE_LIMIT < data[0].totalCount; + // Before first fetch, assume there are more results (optimistic) + // After fetch, use actual totalCount from response + const hasMoreResults = data === undefined + ? true + : !!(data && data[0] && size * PAGE_LIMIT < data[0].totalCount); const totalSearchResultCount = (data && data[0] && data[0].totalCount) || null; const climbSearchResults = useMemo( @@ -87,10 +91,11 @@ export const useQueueDataFetching = ({ return; // Skip if we've already fetched these exact UUIDs } - console.log('Fetching logbook for UUIDs:', climbUuidsString); // Debug log const climbUuids = JSON.parse(climbUuidsString); if (climbUuids.length > 0) { - getLogbook(climbUuids); + getLogbook(climbUuids).catch(() => { + // Error is already handled in getLogbook, just prevent unhandled rejection + }); fetchedUuidsRef.current = climbUuidsString; } }, [climbUuidsString, getLogbook]); From 0b979440f2723acee27652e82c5ae59c77ecfd4e Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Thu, 25 Dec 2025 20:05:18 +1100 Subject: [PATCH 2/2] fix: Update test expectation for hasMoreResults when data is undefined MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test was expecting hasMoreResults to be falsy when data is undefined, but we intentionally changed the behavior to return true (optimistically assume there are more results before the first fetch completes). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../__tests__/hooks/use-queue-data-fetching.test.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/web/app/components/queue-control/__tests__/hooks/use-queue-data-fetching.test.tsx b/packages/web/app/components/queue-control/__tests__/hooks/use-queue-data-fetching.test.tsx index 01e6da98..60e02405 100644 --- a/packages/web/app/components/queue-control/__tests__/hooks/use-queue-data-fetching.test.tsx +++ b/packages/web/app/components/queue-control/__tests__/hooks/use-queue-data-fetching.test.tsx @@ -97,7 +97,7 @@ const mockQueue: ClimbQueue = []; describe('useQueueDataFetching', () => { const mockSetHasDoneFirstFetch = vi.fn(); - const mockGetLogbook = vi.fn(); + const mockGetLogbook = vi.fn().mockResolvedValue([]); const mockSetSize = vi.fn(); beforeEach(() => { @@ -252,7 +252,8 @@ describe('useQueueDataFetching', () => { expect(result.current.climbSearchResults).toBeNull(); expect(result.current.suggestedClimbs).toEqual([]); expect(result.current.totalSearchResultCount).toBeNull(); - expect(result.current.hasMoreResults).toBeFalsy(); + // Before first fetch completes, we optimistically assume there are more results + expect(result.current.hasMoreResults).toBe(true); }); it('should handle fetchMoreClimbs', () => {