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/__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', () => { 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]);