perf(worksheets): use Suspense streaming for preview generation
Problem: The worksheet page had 1.7-2.3s TTFB because the 1.25MB SVG preview was being serialized into the initial HTML response, blocking first paint. Solution: Use React Suspense to stream the preview separately: - Page shell renders immediately with settings (~200ms TTFB) - Preview generates async and streams in when ready (~1.5s later) - User sees the UI instantly, preview appears with loading skeleton New components: - StreamedPreview: async server component that generates preview - PreviewSkeleton: loading placeholder while streaming - StreamedPreviewContext: shares streamed data with PreviewCenter - PreviewDataInjector: bridges server-streamed data to client context Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
1e43ec18f3
commit
2b5d66f776
|
|
@ -726,7 +726,11 @@
|
|||
"Bash(do curl -s -o /dev/null -w \"TTFB: %{time_starttransfer}s | Total: %{time_total}s\\\\n\" https://abaci.one/dashboard)",
|
||||
"Bash(do curl -s -o /dev/null -w \"TTFB: %{time_starttransfer}s | Total: %{time_total}s\\\\n\" https://abaci.one/tutorials)",
|
||||
"Bash(do curl -s -o /dev/null -w \"TTFB: %{time_starttransfer}s | Total: %{time_total}s\\\\n\" https://abaci.one/favicon.ico)",
|
||||
"Bash(ping:*)"
|
||||
"Bash(ping:*)",
|
||||
"Bash(do echo \"=== $pod ===\")",
|
||||
"Bash(do echo \"=== Request $i ===\")",
|
||||
"Bash(while read -r line)",
|
||||
"Bash(do echo $#ine)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
|
|
|||
|
|
@ -119,7 +119,15 @@
|
|||
"mcp__chrome-devtools__get_network_request",
|
||||
"Bash(npm run db:push:*)",
|
||||
"Bash(kubectl get:*)",
|
||||
"Bash(kubectl logs:*)"
|
||||
"Bash(kubectl logs:*)",
|
||||
"Bash(kubectl rollout:*)",
|
||||
"Bash(kubectl describe:*)",
|
||||
"Bash(docker pull:*)",
|
||||
"Bash(helm show values:*)",
|
||||
"WebFetch(domain:raw.githubusercontent.com)",
|
||||
"Bash(helm get values:*)",
|
||||
"Bash(kubectl set:*)",
|
||||
"Bash(kubectl annotate:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { css } from '@styled/css'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import type { WorksheetFormState } from '@/app/create/worksheets/types'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
|
|
@ -15,16 +15,21 @@ import { ConfigSidebar } from './ConfigSidebar'
|
|||
import { GenerationErrorDisplay } from './GenerationErrorDisplay'
|
||||
import { PreviewCenter } from './PreviewCenter'
|
||||
import { ResponsivePanelLayout } from './ResponsivePanelLayout'
|
||||
import { StreamedPreviewProvider } from './StreamedPreviewContext'
|
||||
import { WorksheetConfigProvider } from './WorksheetConfigContext'
|
||||
|
||||
interface AdditionWorksheetClientProps {
|
||||
initialSettings: Omit<WorksheetFormState, 'date' | 'rows' | 'total'>
|
||||
/** @deprecated Use streamedPreview instead */
|
||||
initialPreview?: string[]
|
||||
/** Suspense boundary containing the streaming preview */
|
||||
streamedPreview?: ReactNode
|
||||
}
|
||||
|
||||
export function AdditionWorksheetClient({
|
||||
initialSettings,
|
||||
initialPreview,
|
||||
streamedPreview,
|
||||
}: AdditionWorksheetClientProps) {
|
||||
const searchParams = useSearchParams()
|
||||
const isFromShare = searchParams.get('from') === 'share'
|
||||
|
|
@ -79,36 +84,41 @@ export function AdditionWorksheetClient({
|
|||
|
||||
return (
|
||||
<PageWithNav navTitle={t('navTitle')} navEmoji="📝">
|
||||
<WorksheetConfigProvider formState={formState} updateFormState={updateFormState}>
|
||||
<div
|
||||
data-component="addition-worksheet-page"
|
||||
className={css({
|
||||
height: '100vh',
|
||||
bg: isDark ? 'gray.900' : 'gray.50',
|
||||
paddingTop: 'var(--app-nav-height)',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
})}
|
||||
>
|
||||
{/* Responsive Panel Layout (desktop) or Drawer (mobile) */}
|
||||
<ResponsivePanelLayout
|
||||
config={formState}
|
||||
sidebarContent={<ConfigSidebar isSaving={isSaving} lastSaved={lastSaved} />}
|
||||
previewContent={
|
||||
<PreviewCenter
|
||||
formState={debouncedFormState}
|
||||
initialPreview={initialPreview}
|
||||
onGenerate={handleGenerate}
|
||||
status={status}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<StreamedPreviewProvider>
|
||||
<WorksheetConfigProvider formState={formState} updateFormState={updateFormState}>
|
||||
{/* Render the streamed preview Suspense boundary - it injects data into context */}
|
||||
{streamedPreview}
|
||||
|
||||
{/* Error Display */}
|
||||
<GenerationErrorDisplay error={error} visible={status === 'error'} onRetry={reset} />
|
||||
</div>
|
||||
</WorksheetConfigProvider>
|
||||
<div
|
||||
data-component="addition-worksheet-page"
|
||||
className={css({
|
||||
height: '100vh',
|
||||
bg: isDark ? 'gray.900' : 'gray.50',
|
||||
paddingTop: 'var(--app-nav-height)',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
})}
|
||||
>
|
||||
{/* Responsive Panel Layout (desktop) or Drawer (mobile) */}
|
||||
<ResponsivePanelLayout
|
||||
config={formState}
|
||||
sidebarContent={<ConfigSidebar isSaving={isSaving} lastSaved={lastSaved} />}
|
||||
previewContent={
|
||||
<PreviewCenter
|
||||
formState={debouncedFormState}
|
||||
initialPreview={initialPreview}
|
||||
onGenerate={handleGenerate}
|
||||
status={status}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Error Display */}
|
||||
<GenerationErrorDisplay error={error} visible={status === 'error'} onRetry={reset} />
|
||||
</div>
|
||||
</WorksheetConfigProvider>
|
||||
</StreamedPreviewProvider>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { extractConfigFields } from '../utils/extractConfigFields'
|
|||
import { FloatingPageIndicator } from './FloatingPageIndicator'
|
||||
import { LoadShareCodeModal } from './LoadShareCodeModal'
|
||||
import { ShareModal } from './ShareModal'
|
||||
import { useStreamedPreview } from './StreamedPreviewContext'
|
||||
import { useWorksheetConfig } from './WorksheetConfigContext'
|
||||
import { WorksheetPreview } from './WorksheetPreview'
|
||||
import { DuplicateWarningBanner } from './worksheet-preview/DuplicateWarningBanner'
|
||||
|
|
@ -39,8 +40,12 @@ export function PreviewCenter({
|
|||
const router = useRouter()
|
||||
const { resolvedTheme } = useTheme()
|
||||
const { onChange } = useWorksheetConfig()
|
||||
const { streamedPages, isLoaded: isStreamedLoaded } = useStreamedPreview()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Use streamed preview from context if available, otherwise fall back to prop
|
||||
const effectivePreview = streamedPages ?? initialPreview
|
||||
const [isScrolling, setIsScrolling] = useState(false)
|
||||
const scrollTimeoutRef = useRef<NodeJS.Timeout>()
|
||||
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false)
|
||||
|
|
@ -582,7 +587,7 @@ export function PreviewCenter({
|
|||
>
|
||||
<WorksheetPreview
|
||||
formState={formState}
|
||||
initialData={initialPreview}
|
||||
initialData={effectivePreview}
|
||||
isScrolling={isScrolling}
|
||||
onPageDataReady={setPageData}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useStreamedPreview } from './StreamedPreviewContext'
|
||||
|
||||
interface PreviewDataInjectorProps {
|
||||
pages?: string[]
|
||||
totalPages?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Client component that receives streamed preview data from the server
|
||||
* and injects it into the StreamedPreviewContext for use by PreviewCenter.
|
||||
*
|
||||
* This component renders nothing visible - it just bridges the server-streamed
|
||||
* data into the client-side context system.
|
||||
*/
|
||||
export function PreviewDataInjector({ pages, totalPages }: PreviewDataInjectorProps) {
|
||||
const { setStreamedPreview } = useStreamedPreview()
|
||||
|
||||
useEffect(() => {
|
||||
console.log('[PreviewDataInjector] Received streamed preview:', {
|
||||
pageCount: pages?.length ?? 0,
|
||||
totalPages,
|
||||
})
|
||||
setStreamedPreview(pages, totalPages)
|
||||
}, [pages, totalPages, setStreamedPreview])
|
||||
|
||||
// This component doesn't render anything visible
|
||||
return null
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
'use client'
|
||||
|
||||
import { css } from '@styled/css'
|
||||
|
||||
/**
|
||||
* Loading skeleton shown while the worksheet preview is being generated
|
||||
* and streamed from the server.
|
||||
*/
|
||||
export function PreviewSkeleton() {
|
||||
return (
|
||||
<div
|
||||
data-component="preview-skeleton"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '4',
|
||||
padding: '8',
|
||||
minHeight: '400px',
|
||||
})}
|
||||
>
|
||||
{/* Worksheet page placeholder */}
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
maxWidth: '600px',
|
||||
aspectRatio: '11/8.5', // Letter landscape
|
||||
backgroundColor: 'gray.200',
|
||||
borderRadius: 'lg',
|
||||
animation: 'pulse 2s ease-in-out infinite',
|
||||
_dark: {
|
||||
backgroundColor: 'gray.700',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* Loading text */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
color: 'gray.500',
|
||||
fontSize: 'sm',
|
||||
_dark: {
|
||||
color: 'gray.400',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<LoadingSpinner />
|
||||
<span>Generating preview...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LoadingSpinner() {
|
||||
return (
|
||||
<svg
|
||||
className={css({
|
||||
width: '4',
|
||||
height: '4',
|
||||
animation: 'spin 1s linear infinite',
|
||||
})}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className={css({ opacity: 0.25 })}
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className={css({ opacity: 0.75 })}
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import type { WorksheetFormState } from '@/app/create/worksheets/types'
|
||||
import { generateWorksheetPreview } from '../generatePreview'
|
||||
import { PreviewDataInjector } from './PreviewDataInjector'
|
||||
|
||||
interface StreamedPreviewProps {
|
||||
config: WorksheetFormState
|
||||
}
|
||||
|
||||
/**
|
||||
* Async server component that generates the worksheet preview.
|
||||
* This runs inside a Suspense boundary, so it streams to the client
|
||||
* when ready without blocking the initial page render.
|
||||
*/
|
||||
export async function StreamedPreview({ config }: StreamedPreviewProps) {
|
||||
const startTime = Date.now()
|
||||
console.log('[StreamedPreview] Starting preview generation...')
|
||||
|
||||
// Pre-generate first 3 pages (or fewer if config has fewer pages)
|
||||
const INITIAL_PAGES = 3
|
||||
const pagesToGenerate = Math.min(INITIAL_PAGES, config.pages ?? 1)
|
||||
|
||||
const previewResult = await generateWorksheetPreview(config, 0, pagesToGenerate - 1)
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
console.log(
|
||||
`[StreamedPreview] Preview generation complete in ${elapsed}ms:`,
|
||||
previewResult.success ? 'success' : 'failed'
|
||||
)
|
||||
|
||||
// Return a client component that injects the preview data into context
|
||||
return (
|
||||
<PreviewDataInjector
|
||||
pages={previewResult.success ? previewResult.pages : undefined}
|
||||
totalPages={previewResult.totalPages}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
'use client'
|
||||
|
||||
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'
|
||||
|
||||
interface StreamedPreviewContextValue {
|
||||
/**
|
||||
* The streamed preview pages (SVG strings)
|
||||
* undefined = not yet received, [] = received but empty/failed
|
||||
*/
|
||||
streamedPages: string[] | undefined
|
||||
|
||||
/**
|
||||
* Total number of pages in the worksheet (may be more than streamed pages)
|
||||
*/
|
||||
totalPages: number | undefined
|
||||
|
||||
/**
|
||||
* Whether the streamed preview has been received
|
||||
*/
|
||||
isLoaded: boolean
|
||||
|
||||
/**
|
||||
* Called by PreviewDataInjector when streamed data arrives
|
||||
*/
|
||||
setStreamedPreview: (pages: string[] | undefined, totalPages: number | undefined) => void
|
||||
}
|
||||
|
||||
const StreamedPreviewContext = createContext<StreamedPreviewContextValue | null>(null)
|
||||
|
||||
interface StreamedPreviewProviderProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides streamed preview data to child components.
|
||||
* The preview is generated server-side and streamed via Suspense,
|
||||
* then injected into this context by PreviewDataInjector.
|
||||
*/
|
||||
export function StreamedPreviewProvider({ children }: StreamedPreviewProviderProps) {
|
||||
const [streamedPages, setStreamedPages] = useState<string[] | undefined>(undefined)
|
||||
const [totalPages, setTotalPages] = useState<number | undefined>(undefined)
|
||||
const [isLoaded, setIsLoaded] = useState(false)
|
||||
|
||||
const setStreamedPreview = useCallback(
|
||||
(pages: string[] | undefined, total: number | undefined) => {
|
||||
setStreamedPages(pages)
|
||||
setTotalPages(total)
|
||||
setIsLoaded(true)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<StreamedPreviewContext.Provider
|
||||
value={{
|
||||
streamedPages,
|
||||
totalPages,
|
||||
isLoaded,
|
||||
setStreamedPreview,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</StreamedPreviewContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access streamed preview data.
|
||||
* Must be used within a StreamedPreviewProvider.
|
||||
*/
|
||||
export function useStreamedPreview(): StreamedPreviewContextValue {
|
||||
const context = useContext(StreamedPreviewContext)
|
||||
if (!context) {
|
||||
throw new Error('useStreamedPreview must be used within StreamedPreviewProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
import { Suspense } from 'react'
|
||||
import { eq, and } from 'drizzle-orm'
|
||||
import { db, schema } from '@/db'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
import { parseAdditionConfig, defaultAdditionConfig } from '@/app/create/worksheets/config-schemas'
|
||||
import { AdditionWorksheetClient } from './components/AdditionWorksheetClient'
|
||||
import { WorksheetErrorBoundary } from './components/WorksheetErrorBoundary'
|
||||
import { PreviewSkeleton } from './components/PreviewSkeleton'
|
||||
import { StreamedPreview } from './components/StreamedPreview'
|
||||
import type { WorksheetFormState } from '@/app/create/worksheets/types'
|
||||
import { generateWorksheetPreview } from './generatePreview'
|
||||
|
||||
/**
|
||||
* Get current date formatted as "Month Day, Year"
|
||||
|
|
@ -57,13 +59,6 @@ async function loadWorksheetSettings(): Promise<
|
|||
const savedSeed = (config as any).seed
|
||||
const finalSeed = savedSeed ?? Date.now() % 2147483647
|
||||
|
||||
console.log('[loadWorksheetSettings] Loaded from DB:', {
|
||||
hasSavedSeed: !!savedSeed,
|
||||
savedSeed,
|
||||
finalSeed,
|
||||
prngAlgorithm: (config as any).prngAlgorithm,
|
||||
})
|
||||
|
||||
return {
|
||||
...config,
|
||||
seed: finalSeed,
|
||||
|
|
@ -80,48 +75,54 @@ async function loadWorksheetSettings(): Promise<
|
|||
}
|
||||
}
|
||||
|
||||
export default async function AdditionWorksheetPage() {
|
||||
const pageStart = Date.now()
|
||||
|
||||
console.log('[SSR] Starting worksheet page render...')
|
||||
const settingsStart = Date.now()
|
||||
const initialSettings = await loadWorksheetSettings()
|
||||
console.log(`[SSR] loadWorksheetSettings: ${Date.now() - settingsStart}ms`)
|
||||
|
||||
// Calculate derived state needed for preview
|
||||
// Use defaults for required fields (loadWorksheetSettings should always provide these, but TypeScript needs guarantees)
|
||||
const problemsPerPage = initialSettings.problemsPerPage ?? 20
|
||||
const pages = initialSettings.pages ?? 1
|
||||
const cols = initialSettings.cols ?? 5
|
||||
|
||||
/**
|
||||
* Build full config from settings
|
||||
*/
|
||||
function buildFullConfig(
|
||||
settings: Omit<WorksheetFormState, 'date' | 'rows' | 'total'>
|
||||
): WorksheetFormState {
|
||||
const problemsPerPage = settings.problemsPerPage ?? 20
|
||||
const pages = settings.pages ?? 1
|
||||
const cols = settings.cols ?? 5
|
||||
const rows = Math.ceil((problemsPerPage * pages) / cols)
|
||||
const total = problemsPerPage * pages
|
||||
|
||||
// Create full config for preview generation
|
||||
const fullConfig: WorksheetFormState = {
|
||||
...initialSettings,
|
||||
return {
|
||||
...settings,
|
||||
rows,
|
||||
total,
|
||||
date: getDefaultDate(),
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-generate ONLY the first 3 pages on the server
|
||||
// The virtualization system will handle loading additional pages on-demand
|
||||
const INITIAL_PAGES = 3
|
||||
const pagesToGenerate = Math.min(INITIAL_PAGES, pages)
|
||||
console.log(`[SSR] Generating initial ${pagesToGenerate} pages on server (total: ${pages})...`)
|
||||
const previewStart = Date.now()
|
||||
const previewResult = await generateWorksheetPreview(fullConfig, 0, pagesToGenerate - 1)
|
||||
console.log(`[SSR] generateWorksheetPreview: ${Date.now() - previewStart}ms`)
|
||||
console.log(`[SSR] Total page render: ${Date.now() - pageStart}ms`)
|
||||
console.log('[SSR] Preview generation complete:', previewResult.success ? 'success' : 'failed')
|
||||
/**
|
||||
* Worksheet page with Suspense streaming for preview
|
||||
*
|
||||
* Architecture:
|
||||
* 1. Settings load fast (~50ms) - page shell renders immediately
|
||||
* 2. Preview generates async (~500ms) and streams in via Suspense
|
||||
* 3. User sees the page UI in ~200ms, preview appears when ready
|
||||
*/
|
||||
export default async function AdditionWorksheetPage() {
|
||||
const pageStart = Date.now()
|
||||
|
||||
// Pass settings and preview to client, wrapped in error boundary
|
||||
// Fast path: load settings from DB
|
||||
const initialSettings = await loadWorksheetSettings()
|
||||
console.log(`[SSR] Settings loaded in ${Date.now() - pageStart}ms`)
|
||||
|
||||
// Build full config for preview generation
|
||||
const fullConfig = buildFullConfig(initialSettings)
|
||||
|
||||
// Page shell renders immediately, preview streams in via Suspense
|
||||
return (
|
||||
<WorksheetErrorBoundary>
|
||||
<AdditionWorksheetClient
|
||||
initialSettings={initialSettings}
|
||||
initialPreview={previewResult.success ? previewResult.pages : undefined}
|
||||
streamedPreview={
|
||||
<Suspense fallback={<PreviewSkeleton />}>
|
||||
<StreamedPreview config={fullConfig} />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
</WorksheetErrorBoundary>
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue