diff --git a/components/studio/features/history/gallery-view.tsx b/components/studio/features/history/gallery-view.tsx index c24d675..68d597e 100644 --- a/components/studio/features/history/gallery-view.tsx +++ b/components/studio/features/history/gallery-view.tsx @@ -46,7 +46,7 @@ export const GalleryView = React.memo(function GalleryView({ initialPage, }: GalleryViewProps) { return ( -
+
) -}) \ No newline at end of file +}) diff --git a/components/studio/gallery/image-gallery.test.tsx b/components/studio/gallery/image-gallery.test.tsx index ab2d134..64c6ced 100644 --- a/components/studio/gallery/image-gallery.test.tsx +++ b/components/studio/gallery/image-gallery.test.tsx @@ -219,6 +219,20 @@ describe("ImageGallery", () => { expect(screen.queryByTestId("gallery-empty")).not.toBeInTheDocument(); }); + it("renders a scrollable mobile container with vaul drag disabled", () => { + vi.mocked(useIsMobile).mockReturnValue(true); + + render(); + + const scrollContainer = screen.getByTestId( + "gallery-scroll-container-mobile", + ); + expect(scrollContainer).toBeInTheDocument(); + expect(scrollContainer).toHaveClass("overflow-y-auto"); + expect(scrollContainer).toHaveAttribute("data-vaul-no-drag"); + }); + + it("shows loading state when isLoading is true", () => { render(); diff --git a/components/studio/gallery/image-gallery.tsx b/components/studio/gallery/image-gallery.tsx index 4e666a0..ad8d4ac 100644 --- a/components/studio/gallery/image-gallery.tsx +++ b/components/studio/gallery/image-gallery.tsx @@ -249,7 +249,7 @@ export const ImageGallery = React.memo(function ImageGallery({
{isMobile ? ( - // Mobile: Render direct div to allow parent (Drawer) to handle scrolling + // Mobile: Use native scroll container for smooth touch + reliable virtualization
{renderGalleryContent()}
diff --git a/components/studio/gallery/virtualized-gallery-grid.test.tsx b/components/studio/gallery/virtualized-gallery-grid.test.tsx index 1e6b902..3db8d6e 100644 --- a/components/studio/gallery/virtualized-gallery-grid.test.tsx +++ b/components/studio/gallery/virtualized-gallery-grid.test.tsx @@ -218,14 +218,17 @@ describe("VirtualizedGalleryGrid", () => { it("uses 800px rootMargin on mobile for infinite scroll", async () => { vi.useFakeTimers(); vi.mocked(useIsMobile).mockReturnValue(true); + const props = createDefaultProps(); render( ); + props.scrollContainerRef.current!.scrollTop = 1; + props.scrollContainerRef.current!.dispatchEvent(new Event("scroll")); await act(async () => { await vi.advanceTimersByTimeAsync(500); diff --git a/components/studio/gallery/virtualized-gallery-grid.tsx b/components/studio/gallery/virtualized-gallery-grid.tsx index 7e3699f..8555335 100644 --- a/components/studio/gallery/virtualized-gallery-grid.tsx +++ b/components/studio/gallery/virtualized-gallery-grid.tsx @@ -131,11 +131,12 @@ export const VirtualizedGalleryGrid = React.memo(function VirtualizedGalleryGrid // Get mobile drawer visibility state (undefined when not in a drawer) const drawerState = useMobileDrawerVisibility(); const isDrawerVisible = drawerState?.isVisible ?? true; // Default to true for desktop + const [hasUserScrolled, setHasUserScrolled] = React.useState(false); // Track when drawer becomes visible to add a small delay for animation const prevDrawerVisibleRef = React.useRef(isDrawerVisible); const [observerEnabled, setObserverEnabled] = React.useState( - !isMobile || isDrawerVisible + !isMobile || (isDrawerVisible && hasUserScrolled) ); React.useEffect(() => { @@ -143,6 +144,18 @@ export const VirtualizedGalleryGrid = React.memo(function VirtualizedGalleryGrid return () => setIsMounted(false); }, []); + React.useEffect(() => { + const el = scrollContainerRef.current; + if (!el) return; + const onScroll = () => { + if (!hasUserScrolled && el.scrollTop > 0) { + setHasUserScrolled(true); + } + }; + el.addEventListener("scroll", onScroll, { passive: true }); + return () => el.removeEventListener("scroll", onScroll); + }, [scrollContainerRef, hasUserScrolled]); + // Handle drawer visibility changes React.useEffect(() => { const wasVisible = prevDrawerVisibleRef.current; @@ -153,7 +166,7 @@ export const VirtualizedGalleryGrid = React.memo(function VirtualizedGalleryGrid // Small delay to let drawer animation complete before enabling observer // This prevents the sentinel from being detected during the animation const timer = setTimeout(() => { - setObserverEnabled(true); + setObserverEnabled(hasUserScrolled); }, 250); // Animation reduced to 200ms + 50ms buffer return () => clearTimeout(timer); @@ -169,8 +182,12 @@ export const VirtualizedGalleryGrid = React.memo(function VirtualizedGalleryGrid setObserverEnabled(true); } + if (isMobile && isNowVisible && hasUserScrolled) { + setObserverEnabled(true); + } + prevDrawerVisibleRef.current = isNowVisible; - }, [isDrawerVisible, isMobile]); + }, [isDrawerVisible, isMobile, hasUserScrolled]); // Infinite scroll: trigger loadMore when sentinel becomes visible React.useEffect(() => { @@ -189,23 +206,6 @@ export const VirtualizedGalleryGrid = React.memo(function VirtualizedGalleryGrid return; } - // On mobile in a drawer, the actual scroll container is the drawer's container, not our scrollContainerRef - // The drawer has its own scroll container that wraps the gallery - // We need to find it by traversing up the DOM - let actualScrollContainer = scrollContainer; - if (isMobile && drawerState) { - // Look for the drawer's scroll container (has overflow-y-auto and is a parent of our container) - let parent = scrollContainer.parentElement; - while (parent) { - const styles = window.getComputedStyle(parent); - if (styles.overflowY === "auto" || styles.overflowY === "scroll") { - actualScrollContainer = parent as HTMLDivElement; - break; - } - parent = parent.parentElement; - } - } - // Debounce to prevent rapid-fire requests let debounceTimer: ReturnType | null = null; const debouncedLoadMore = () => { @@ -225,18 +225,14 @@ export const VirtualizedGalleryGrid = React.memo(function VirtualizedGalleryGrid // This prevents automatic fetching when drawer opens (scrollTop is 0) // The IntersectionObserver fires immediately on observe() if target is visible, // which can happen during drawer animation before layout stabilizes - if ( - isMobile && - actualScrollContainer && - actualScrollContainer.scrollTop === 0 - ) { + if (isMobile && drawerState && !hasUserScrolled) { return; } debouncedLoadMore(); } }, { - root: actualScrollContainer, + root: scrollContainer, // Desktop & Mobile: 800px look-ahead for aggressive pre-fetching // This triggers loading well before the user reaches the bottom rootMargin: "0px 0px 800px 0px", @@ -259,37 +255,24 @@ export const VirtualizedGalleryGrid = React.memo(function VirtualizedGalleryGrid isMobile, drawerState, scrollContainerRef, + hasUserScrolled, ]); // Failsafe: Check if we need to load more immediately after a load finishes // This handles cases where the observer might not re-fire (e.g. if we are already intersecting) React.useEffect(() => { if (!isLoadingMore && canLoadMore && isMounted && onLoadMore) { + if (isMobile && drawerState && !hasUserScrolled) { + return; + } // Small delay to allow layout to settle const timer = setTimeout(() => { const sentinel = sentinelRef.current; const scrollContainer = scrollContainerRef.current; if (sentinel && scrollContainer) { - // Determine the actual scroll container (handling mobile drawer case) - let actualScrollContainer = scrollContainer; - if (isMobile && drawerState) { - let parent = scrollContainer.parentElement; - while (parent) { - const styles = window.getComputedStyle(parent); - if ( - styles.overflowY === "auto" || - styles.overflowY === "scroll" - ) { - actualScrollContainer = parent as HTMLDivElement; - break; - } - parent = parent.parentElement; - } - } - const sentinelRect = sentinel.getBoundingClientRect(); - const containerRect = actualScrollContainer.getBoundingClientRect(); + const containerRect = scrollContainer.getBoundingClientRect(); const rootMargin = 800; // Match the 800px margin in IntersectionObserver // If sentinel top is above (container bottom + margin) @@ -300,7 +283,7 @@ export const VirtualizedGalleryGrid = React.memo(function VirtualizedGalleryGrid sentinelRect.bottom >= containerRect.top ) { // On mobile, also check scrollTop to respect the "don't load on open" rule - if (isMobile && actualScrollContainer.scrollTop === 0) { + if (isMobile && drawerState && scrollContainer.scrollTop === 0) { return; } onLoadMore(); @@ -317,6 +300,7 @@ export const VirtualizedGalleryGrid = React.memo(function VirtualizedGalleryGrid isMobile, drawerState, scrollContainerRef, + hasUserScrolled, ]); return ( diff --git a/components/studio/mobile/mobile-editor-drawer.test.tsx b/components/studio/mobile/mobile-editor-drawer.test.tsx index 53cd772..2803c85 100644 --- a/components/studio/mobile/mobile-editor-drawer.test.tsx +++ b/components/studio/mobile/mobile-editor-drawer.test.tsx @@ -110,6 +110,16 @@ describe("MobileEditorDrawer", () => { }) }) + describe("Drawer Configuration", () => { + it("constrains drawer height for scrollable content", () => { + render() + + const content = screen.getByTestId("mobile-editor-drawer") + expect(content).toHaveClass("h-[85dvh]") + expect(content).toHaveClass("max-h-[85dvh]") + }) + }) + /** * Snap Point Behavior Tests * @@ -178,4 +188,3 @@ describe("MobileEditorDrawer", () => { }) }) }) - diff --git a/components/studio/mobile/mobile-editor-drawer.tsx b/components/studio/mobile/mobile-editor-drawer.tsx index f32abd8..c218b22 100644 --- a/components/studio/mobile/mobile-editor-drawer.tsx +++ b/components/studio/mobile/mobile-editor-drawer.tsx @@ -82,7 +82,7 @@ export function MobileEditorDrawer({ { expect(content).toHaveClass("bg-card/95"); expect(content).toHaveClass("backdrop-blur-xl"); }); + + it("constrains drawer height for scrollable content", () => { + render(); + + const content = screen.getByTestId("mobile-history-drawer"); + expect(content).toHaveClass("h-[85dvh]"); + expect(content).toHaveClass("max-h-[85dvh]"); + }); }); describe("Custom className", () => { diff --git a/components/studio/mobile/mobile-history-drawer.tsx b/components/studio/mobile/mobile-history-drawer.tsx index 190f6c7..3c3cac4 100644 --- a/components/studio/mobile/mobile-history-drawer.tsx +++ b/components/studio/mobile/mobile-history-drawer.tsx @@ -120,7 +120,7 @@ export function MobileHistoryDrawer({ - {/* Scrollable content area - native scroll for better touch handling */} -
+
{/* Provide drawer visibility to children for infinite scroll control */} { }); }); + describe("SSR hydration mismatch behavior", () => { + it("closes drawers when isMobile flips to true after mount", async () => { + mockUseIsMobile.mockReturnValue(false); + const { result, rerender } = renderHook(() => useStudioUI()); + + expect(result.current.showLeftSidebar).toBe(true); + expect(result.current.showGallery).toBe(true); + + mockUseIsMobile.mockReturnValue(true); + rerender(); + + await waitFor(() => { + expect(result.current.showLeftSidebar).toBe(false); + expect(result.current.showGallery).toBe(false); + }); + }); + }); + describe("Lightbox functionality", () => { it("opens lightbox with image", () => { const { result } = renderHook(() => useStudioUI()); diff --git a/hooks/use-studio-ui.ts b/hooks/use-studio-ui.ts index 21077b9..7ffe017 100644 --- a/hooks/use-studio-ui.ts +++ b/hooks/use-studio-ui.ts @@ -87,16 +87,11 @@ export function useStudioUI(): UseStudioUIReturn { // Sync drawer state on mobile after hydration // This runs once after the client-side isMobile value is determined React.useEffect(() => { - // Only run once, on initial client-side mount + if (!isMobile) return; if (hasInitializedMobileRef.current) return; hasInitializedMobileRef.current = true; - - // If we're on mobile, ensure drawers are closed - // This corrects the SSR mismatch where isMobile was false on server - if (isMobile) { - setShowLeftSidebar(false); - setShowGallery(false); - } + setShowLeftSidebar(false); + setShowGallery(false); }, [isMobile]); // ========================================