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]);
// ========================================