-
Notifications
You must be signed in to change notification settings - Fork 112
fix: content scroll when switching database views #161
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,10 +20,10 @@ const logDebug = (...args: Parameters<typeof console.debug>) => { | |
| export function useGridVirtualizer({ data, columns }: { columns: RenderColumn[]; data: RenderRow[] }) { | ||
| const { isDocumentBlock, paddingStart, paddingEnd } = useDatabaseContext(); | ||
| const parentRef = useRef<HTMLDivElement | null>(null); | ||
|
|
||
| const parentOffsetRef = useRef(0); | ||
| const parentOffsetRef = useRef<number | null>(null); | ||
| const [parentOffset, setParentOffset] = useState(0); | ||
| const rafIdRef = useRef<number>(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue (complexity): Consider simplifying the new parent offset logic by using a null check, a single threshold constant, and a small RAF helper to reduce branching and make updateParentOffset easier to follow. You can keep all the new behavior (null-initialized offset, no reset on view switches, multi-RAF on first measurement) with less branching by:
Concretely, you can refactor
// outside the hook or at top-level in the file
const PARENT_OFFSET_STABLE_THRESHOLD = 5;
// inside the hook
const parentOffsetRef = useRef<number | null>(null);
// remove isInitialMountRef entirely
// inside the hook
const runAfterRafs = useCallback((count: number, fn: () => void) => {
let rafs = 0;
const tick = () => {
rafs += 1;
if (rafs < count) {
rafIdRef.current = requestAnimationFrame(tick);
return;
}
fn();
};
rafIdRef.current = requestAnimationFrame(tick);
}, []);Then const updateParentOffset = useCallback(() => {
if (rafIdRef.current !== undefined) {
cancelAnimationFrame(rafIdRef.current);
}
const first = measureParentOffset();
if (first === null) {
logDebug('[GridVirtualizer] skip parent offset update; missing refs', {
hasParent: !!parentRef.current,
hasScrollElement: !!getScrollElement(),
});
return;
}
const isFirstMeasurement = parentOffsetRef.current === null;
const rafCount = isFirstMeasurement ? 3 : 1;
runAfterRafs(rafCount, () => {
const measured = measureParentOffset();
const nextOffset = measured ?? first;
// First ever measurement: accept unconditionally
if (isFirstMeasurement) {
parentOffsetRef.current = nextOffset;
setParentOffset(nextOffset);
logDebug('[GridVirtualizer] initial parent offset set', { nextOffset });
return;
}
const delta = Math.abs(nextOffset - parentOffsetRef.current!);
if (delta < PARENT_OFFSET_STABLE_THRESHOLD) {
logDebug('[GridVirtualizer] parent offset stable', {
current: parentOffsetRef.current,
measured: nextOffset,
delta,
threshold: PARENT_OFFSET_STABLE_THRESHOLD,
});
return;
}
parentOffsetRef.current = nextOffset;
setParentOffset(nextOffset);
logDebug('[GridVirtualizer] parent offset updated', {
nextOffset,
previous: parentOffset,
delta,
});
});
}, [measureParentOffset, getScrollElement, parentOffset, runAfterRafs]);
useLayoutEffect(() => {
// Re-measure when data length changes (proxy for view changes),
// but keep the last known parentOffsetRef to avoid scroll jumps.
updateParentOffset();
}, [updateParentOffset, data.length]);This preserves:
But it removes:
|
||
| 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) { | ||
|
Comment on lines
48
to
54
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue (bug_risk): isInitialMountRef is set to false before this branch, so the higher initial threshold is never used. Since |
||
|
|
@@ -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, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suggestion: Using NodeJS.Timeout here may be incompatible with browser timer typings.
In a DOM-centric React app, setTimeout returns a number, not NodeJS.Timeout. This typing can conflict with TS configs that only include
domor with non-Node environments. Consider a more portable type likenumber | nullorReturnType<typeof setTimeout> | nullso it works cleanly across environments.Suggested implementation:
Anywhere in this file where
heightLockTimeoutRef.currentis assigned or cleared (e.g., viasetTimeout/clearTimeout), the existing code should already remain type-correct with this change:heightLockTimeoutRef.current = setTimeout(() => { ... }, delay);if (heightLockTimeoutRef.current) { clearTimeout(heightLockTimeoutRef.current); }clearTimeout(heightLockTimeoutRef.current!);if you use non-null assertion.No further changes should be necessary unless there are explicit type annotations elsewhere that still reference
NodeJS.Timeout; if so, update those to useReturnType<typeof setTimeout>as well for consistency.