better loading state for report

This commit is contained in:
Nate Kelley 2025-09-03 21:41:41 -06:00
parent b8e4b7e70d
commit 2dbe578f3a
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
4 changed files with 156 additions and 22 deletions

View File

@ -421,9 +421,24 @@ const AppSplitterBase = forwardRef<
const startSize = savedLayout ?? 0;
const startTime = performance.now();
// Frame-based animation for smoother performance
// We'll track the expected frame count based on 60fps for timing reference
const expectedFrameCount = Math.ceil((duration / 1000) * 60);
let frameCount = 0;
let lastFrameTime = startTime;
const animate = (currentTime: number) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
frameCount++;
const deltaTime = currentTime - lastFrameTime;
lastFrameTime = currentTime;
// Use frame-based progress as primary, with time-based as fallback
// This ensures smooth animation even when frames are dropped
const frameProgress = frameCount / expectedFrameCount;
const timeProgress = (currentTime - startTime) / duration;
// Use the more conservative progress to prevent overshooting
const progress = Math.min(Math.max(frameProgress, timeProgress), 1);
const easedProgress = easeInOutCubic(progress);
const currentSize = startSize + (targetSize - startSize) * easedProgress;

View File

@ -1,4 +1,5 @@
import { lazy, Suspense } from 'react';
import { type UsePageReadyOptions, usePageReady } from '@/hooks/usePageReady';
import type { ReportEditorProps } from './ReportEditor';
import { ReportEditorSkeleton } from './ReportEditorSkeleton';
@ -10,7 +11,25 @@ const DynamicReportEditorBase = lazy(() =>
})
);
export const DynamicReportEditor = (props: ReportEditorProps) => {
export interface DynamicReportEditorProps extends ReportEditorProps {
loadingOptions?: UsePageReadyOptions;
}
export const DynamicReportEditor = ({ loadingOptions, ...props }: DynamicReportEditorProps) => {
const { delay = 200, idleTimeout = 500, forceImmediate = false } = loadingOptions || {};
const { isReady: isPageReady } = usePageReady({
...loadingOptions,
delay,
idleTimeout,
forceImmediate,
});
// Show skeleton only when not ready (not during transition)
if (!isPageReady) {
return <ReportEditorSkeleton />;
}
return (
<Suspense fallback={<ReportEditorSkeleton />}>
<DynamicReportEditorBase {...props} />

View File

@ -0,0 +1,92 @@
import { useEffect, useState, useTransition } from 'react';
export interface UsePageReadyOptions {
/**
* Delay in milliseconds after initial mount before considering page ready
* @default 25
*/
delay?: number;
/**
* Whether to wait for requestIdleCallback (when browser is idle)
* @default true
*/
waitForIdle?: boolean;
/**
* Timeout for requestIdleCallback in milliseconds
* @default 500
*/
idleTimeout?: number;
/**
* Whether to force immediate loading
* @default false
*/
forceImmediate?: boolean;
/**
* Whether to use React's useTransition for non-urgent updates
* @default true
*/
useTransition?: boolean;
}
/**
* Hook that determines when the page is ready for heavy component loading.
* Uses React's useTransition to defer non-urgent updates and prevent blocking animations.
*/
export const usePageReady = (options: UsePageReadyOptions = {}) => {
const {
delay = 25,
waitForIdle = true,
idleTimeout = 500,
forceImmediate = false,
useTransition: useTransitionFlag = true,
} = options;
const [isReady, setIsReady] = useState(forceImmediate);
const [isPending, startTransition] = useTransition();
useEffect(() => {
if (forceImmediate) {
setIsReady(true);
return;
}
let timeoutId: NodeJS.Timeout;
let idleCallbackId: number;
const markAsReady = () => {
if (useTransitionFlag) {
// Use transition to defer this non-urgent update
startTransition(() => {
setIsReady(true);
});
} else {
setIsReady(true);
}
};
const checkReadiness = () => {
if (waitForIdle && 'requestIdleCallback' in window) {
// Wait for browser to be idle
idleCallbackId = requestIdleCallback(markAsReady, { timeout: idleTimeout });
} else {
// Fallback: just use the delay
markAsReady();
}
};
// Initial delay to let any animations or transitions settle
timeoutId = setTimeout(() => {
// Use requestAnimationFrame to ensure DOM is ready
requestAnimationFrame(() => {
checkReadiness();
});
}, delay);
return () => {
if (timeoutId) clearTimeout(timeoutId);
if (idleCallbackId) cancelIdleCallback(idleCallbackId);
};
}, [delay, waitForIdle, idleTimeout, forceImmediate, useTransitionFlag]);
return { isReady, isPending };
};

View File

@ -1,5 +1,5 @@
import { ClientOnly } from '@tanstack/react-router';
import React, { useRef } from 'react';
import React, { useRef, useState } from 'react';
import { ScrollToBottomButton } from '@/components/features/buttons/ScrollToBottomButton';
import { useGetChatMessageIds } from '@/context/Chats';
import { useAutoScroll } from '@/hooks/useAutoScroll';
@ -14,6 +14,7 @@ const autoClass = 'mx-auto max-w-[600px] w-full';
export const ChatContent: React.FC<{ chatId: string | undefined }> = React.memo(({ chatId }) => {
const chatMessageIds = useGetChatMessageIds(chatId);
const containerRef = useRef<HTMLElement>(null);
const [isMounted, setIsMounted] = useState(false);
const { isAutoScrollEnabled, scrollToBottom, enableAutoScroll } = useAutoScroll(containerRef, {
observeSubTree: true,
@ -27,31 +28,38 @@ export const ChatContent: React.FC<{ chatId: string | undefined }> = React.memo(
if (!container) return;
containerRef.current = container;
enableAutoScroll();
setIsMounted(true);
});
const showScrollToBottomButton = isMounted && containerRef.current;
return (
<ClientOnly>
<div className="mb-48 flex h-full w-full flex-col">
{chatMessageIds?.map((messageId, index) => (
<div key={messageId} className={autoClass}>
<ChatMessageBlock
key={messageId}
messageId={messageId}
chatId={chatId || ''}
messageIndex={index}
/>
</div>
))}
<>
<div className={cn('mb-48 flex h-full w-full flex-col', !isMounted && 'invisible')}>
<ClientOnly>
{chatMessageIds?.map((messageId, index) => (
<div key={messageId} className={autoClass}>
<ChatMessageBlock
key={messageId}
messageId={messageId}
chatId={chatId || ''}
messageIndex={index}
/>
</div>
))}
</ClientOnly>
</div>
<ChatInputWrapper>
<ScrollToBottomButton
isAutoScrollEnabled={isAutoScrollEnabled}
scrollToBottom={scrollToBottom}
className="absolute -top-10"
/>
{showScrollToBottomButton && (
<ScrollToBottomButton
isAutoScrollEnabled={isAutoScrollEnabled}
scrollToBottom={scrollToBottom}
className={'absolute -top-10'}
/>
)}
</ChatInputWrapper>
</ClientOnly>
</>
);
});