diff --git a/src/components/database/DatabaseViews.tsx b/src/components/database/DatabaseViews.tsx index 630f55c5..cced187f 100644 --- a/src/components/database/DatabaseViews.tsx +++ b/src/components/database/DatabaseViews.tsx @@ -45,6 +45,7 @@ function DatabaseViews({ const viewContainerRef = useRef(null); const [lockedHeight, setLockedHeight] = useState(fixedHeight ?? null); const lastScrollRef = useRef(null); + const heightLockTimeoutRef = useRef(null); const value = useMemo(() => { return Math.max( 0, @@ -79,7 +80,6 @@ function DatabaseViews({ }; observerEvent(); - activeView.observe(observerEvent); return () => { @@ -96,6 +96,12 @@ function DatabaseViews({ scrollTop: lastScrollRef.current, }); + // Clear any pending height lock timeout from previous transition + if (heightLockTimeoutRef.current) { + clearTimeout(heightLockTimeoutRef.current); + heightLockTimeoutRef.current = null; + } + const currentHeight = viewContainerRef.current?.offsetHeight; const heightToLock = fixedHeight ?? currentHeight ?? null; @@ -114,7 +120,14 @@ function DatabaseViews({ const view = useMemo(() => { - if (isLoading) return null; + // IMPORTANT: Don't return null during loading to prevent unmounting + // + // Previously: `if (isLoading) return null;` caused the Grid to unmount + // Problem: This reset the virtualizer's parentOffsetRef from 955px → 0px + // Result: Scroll position jumped from 617px → 467px (150px jump) + // + // Solution: Keep components mounted but hide with opacity/visibility + // This preserves virtualizer state and prevents scroll jumps switch (layout) { case DatabaseViewLayout.Grid: return ; @@ -122,8 +135,11 @@ function DatabaseViews({ return ; case DatabaseViewLayout.Calendar: return ; + default: + // Return null only when layout is truly not set + return null; } - }, [layout, isLoading]); + }, [layout]); useEffect(() => { if (!isLoading && viewContainerRef.current) { @@ -140,8 +156,7 @@ function DatabaseViews({ } }, [isLoading, viewVisible, layout, viewId, iidIndex]); - // Scroll restoration with RAF enforcement - // Board's autoScrollForElements interferes with scroll, so we enforce for multiple frames + // Enhanced scroll restoration with better timing coordination useEffect(() => { if (isLoading) return; if (lastScrollRef.current === null) return; @@ -155,61 +170,61 @@ function DatabaseViews({ const targetScroll = lastScrollRef.current; let rafCount = 0; - let rafId: number; - - // Temporarily prevent scroll events during restoration - const preventScroll = (e: Event) => { - if (scrollElement.scrollTop !== targetScroll) { - e.preventDefault(); - scrollElement.scrollTop = targetScroll; - } - }; + const maxRAFs = 3; // Wait 3 animation frames (≈50ms at 60fps) for layout to settle + const rafIds: number[] = []; - scrollElement.addEventListener('scroll', preventScroll, { passive: false }); + const restoreScroll = () => { + rafCount++; - // Use RAF loop to enforce scroll position - const enforceScroll = () => { - const currentScroll = scrollElement.scrollTop; - const delta = Math.abs(currentScroll - targetScroll); + if (rafCount < maxRAFs) { + // Continue waiting for layout to stabilize + // Each RAF waits for the next browser paint (~16ms at 60fps) + const id = requestAnimationFrame(restoreScroll); + rafIds.push(id); + return; + } - if (delta > 0.5) { + // After layout has settled (3 RAFs), restore scroll position + // By this time: + // - New view component has rendered + // - Virtualizer has initialized and measured parentOffset + // - ResizeObserver has fired and stabilized + // - CSS transitions have started + if (Math.abs(scrollElement.scrollTop - targetScroll) > 1) { scrollElement.scrollTop = targetScroll; - logDebug('[DatabaseViews] RAF restore scroll', { - frame: rafCount, + logDebug('[DatabaseViews] restored scroll position after layout settled', { target: targetScroll, - current: currentScroll, - delta, + current: scrollElement.scrollTop, + rafCount, }); } - rafCount++; - // Run for 5 frames (~80ms) to catch delayed scroll changes from Board mount - if (rafCount < 5) { - rafId = requestAnimationFrame(enforceScroll); - } else { - logDebug('[DatabaseViews] scroll restoration completed', { - final: scrollElement.scrollTop, - target: targetScroll, - }); - // Remove scroll listener and clean up - scrollElement.removeEventListener('scroll', preventScroll); - lastScrollRef.current = null; - setViewVisible(true); - // Release height lock to allow view to resize to its natural height - if (!fixedHeight) { + // Clean up and show view + lastScrollRef.current = null; + setViewVisible(true); + + // Release height lock after a small delay to allow content to settle + if (!fixedHeight) { + heightLockTimeoutRef.current = setTimeout(() => { setLockedHeight(null); - } + heightLockTimeoutRef.current = null; + }, 100); } }; - rafId = requestAnimationFrame(enforceScroll); + // Start the RAF chain + const firstId = requestAnimationFrame(restoreScroll); + rafIds.push(firstId); return () => { - if (rafId) { - cancelAnimationFrame(rafId); - } + // Cancel all pending RAFs on cleanup + rafIds.forEach(id => cancelAnimationFrame(id)); - scrollElement.removeEventListener('scroll', preventScroll); + // Cancel pending height lock timeout to prevent stale releases + if (heightLockTimeoutRef.current) { + clearTimeout(heightLockTimeoutRef.current); + heightLockTimeoutRef.current = null; + } }; }, [isLoading, viewId, fixedHeight]); @@ -267,11 +282,14 @@ function DatabaseViews({ >
{view} diff --git a/src/components/database/components/grid/grid-table/useGridVirtualizer.ts b/src/components/database/components/grid/grid-table/useGridVirtualizer.ts index 2606734f..97b86915 100644 --- a/src/components/database/components/grid/grid-table/useGridVirtualizer.ts +++ b/src/components/database/components/grid/grid-table/useGridVirtualizer.ts @@ -20,10 +20,10 @@ const logDebug = (...args: Parameters) => { export function useGridVirtualizer({ data, columns }: { columns: RenderColumn[]; data: RenderRow[] }) { const { isDocumentBlock, paddingStart, paddingEnd } = useDatabaseContext(); const parentRef = useRef(null); - - const parentOffsetRef = useRef(0); + const parentOffsetRef = useRef(null); const [parentOffset, setParentOffset] = useState(0); const rafIdRef = useRef(); + const isInitialMountRef = useRef(true); const getScrollElement = useCallback(() => { if (!parentRef.current) return null; @@ -48,10 +48,7 @@ export function useGridVirtualizer({ data, columns }: { columns: RenderColumn[]; cancelAnimationFrame(rafIdRef.current); } - // Triple RAF to avoid transient measurements during layout thrash when views switch. - // First frame: Initial measurement (may be unstable) - // Second frame: Browser has processed initial layout - // Third frame: Layout is fully settled + // For embedded databases, measure offset more carefully const first = measureParentOffset(); if (first === null) { @@ -62,38 +59,78 @@ export function useGridVirtualizer({ data, columns }: { columns: RenderColumn[]; return; } - rafIdRef.current = requestAnimationFrame(() => { - rafIdRef.current = requestAnimationFrame(() => { - rafIdRef.current = requestAnimationFrame(() => { - const second = measureParentOffset(); - const nextOffset = second ?? first; - const delta = Math.abs(nextOffset - parentOffsetRef.current); - - // Only update if change is significant (>1px) to avoid micro-adjustments - if (delta < 1) { - logDebug('[GridVirtualizer] parent offset stable (delta < 1px)', { - current: parentOffsetRef.current, - measured: nextOffset, - delta, - }); - return; - } - - parentOffsetRef.current = nextOffset; - setParentOffset(nextOffset); - logDebug('[GridVirtualizer] parent offset updated', { - nextOffset, - previous: parentOffset, - delta, - }); + // Use multiple RAFs during initial mount to ensure layout is stable + // This helps prevent scroll jumps during view transitions + const rafCount = isInitialMountRef.current ? 3 : 1; + let currentRaf = 0; + + const performUpdate = () => { + currentRaf++; + + if (currentRaf < rafCount) { + rafIdRef.current = requestAnimationFrame(performUpdate); + return; + } + + const measured = measureParentOffset(); + const nextOffset = measured ?? first; + + // If this is the first measurement, always accept it without threshold check + // This prevents rejecting valid initial offsets (e.g., 955px) that would fail + // the delta check if we started from 0. + if (parentOffsetRef.current === null) { + parentOffsetRef.current = nextOffset; + setParentOffset(nextOffset); + logDebug('[GridVirtualizer] initial parent offset set', { + nextOffset, + isInitialMount: isInitialMountRef.current, }); + isInitialMountRef.current = false; + return; + } + + const delta = Math.abs(nextOffset - parentOffsetRef.current); + + // Only update if change is significant (>10px for initial, >5px after) + // Increased threshold for embedded databases to prevent flashing + const threshold = isInitialMountRef.current ? 10 : 5; + + if (delta < threshold) { + logDebug('[GridVirtualizer] parent offset stable', { + current: parentOffsetRef.current, + measured: nextOffset, + delta, + threshold, + isInitialMount: isInitialMountRef.current, + }); + isInitialMountRef.current = false; + return; + } + + parentOffsetRef.current = nextOffset; + setParentOffset(nextOffset); + logDebug('[GridVirtualizer] parent offset updated', { + nextOffset, + previous: parentOffset, + delta, + isInitialMount: isInitialMountRef.current, }); - }); + isInitialMountRef.current = false; + }; + + rafIdRef.current = requestAnimationFrame(performUpdate); }, [measureParentOffset, getScrollElement, parentOffset]); useLayoutEffect(() => { + // IMPORTANT: We don't reset isInitialMountRef here + // + // The Grid component now stays mounted during view switches (it's just hidden), + // so isInitialMountRef stays false after the first mount. This prevents the + // parentOffsetRef from being reset to null, which would cause scroll jumps. + // + // We watch data.length to detect when the view has changed and needs remeasurement. updateParentOffset(); - }, [updateParentOffset]); + }, [updateParentOffset, data.length]); // Watch data.length for view changes const virtualizer = useVirtualizer({ count: data.length,