perf(worksheets): defer preview to client-side API fetch

The RSC Suspense streaming approach didn't work because the Suspense
boundary was inside a client component's props - React serializes all
props before streaming can begin.

Simpler solution: Don't embed the 1.25MB SVG in initial HTML at all.
- Page SSR returns immediately with just settings (~200ms TTFB)
- Preview is fetched via existing API after hydration (server-side generation)
- User sees page shell instantly, preview loads with loading indicator

This achieves the same UX goal: fast initial paint, preview appears when ready.
The preview generation still happens server-side via the API endpoint.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2026-01-23 19:29:59 -06:00
parent ba08409269
commit 30fb0e86e3
4 changed files with 42 additions and 84 deletions

View File

@ -127,7 +127,8 @@
"WebFetch(domain:raw.githubusercontent.com)", "WebFetch(domain:raw.githubusercontent.com)",
"Bash(helm get values:*)", "Bash(helm get values:*)",
"Bash(kubectl set:*)", "Bash(kubectl set:*)",
"Bash(kubectl annotate:*)" "Bash(kubectl annotate:*)",
"Bash(kubectl run:*)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []

View File

@ -3,7 +3,7 @@
import { css } from '@styled/css' import { css } from '@styled/css'
import { useSearchParams } from 'next/navigation' import { useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { useEffect, useState, type ReactNode } from 'react' import { useEffect, useState } from 'react'
import type { WorksheetFormState } from '@/app/create/worksheets/types' import type { WorksheetFormState } from '@/app/create/worksheets/types'
import { PageWithNav } from '@/components/PageWithNav' import { PageWithNav } from '@/components/PageWithNav'
import { useTheme } from '@/contexts/ThemeContext' import { useTheme } from '@/contexts/ThemeContext'
@ -15,21 +15,17 @@ import { ConfigSidebar } from './ConfigSidebar'
import { GenerationErrorDisplay } from './GenerationErrorDisplay' import { GenerationErrorDisplay } from './GenerationErrorDisplay'
import { PreviewCenter } from './PreviewCenter' import { PreviewCenter } from './PreviewCenter'
import { ResponsivePanelLayout } from './ResponsivePanelLayout' import { ResponsivePanelLayout } from './ResponsivePanelLayout'
import { StreamedPreviewProvider } from './StreamedPreviewContext'
import { WorksheetConfigProvider } from './WorksheetConfigContext' import { WorksheetConfigProvider } from './WorksheetConfigContext'
interface AdditionWorksheetClientProps { interface AdditionWorksheetClientProps {
initialSettings: Omit<WorksheetFormState, 'date' | 'rows' | 'total'> initialSettings: Omit<WorksheetFormState, 'date' | 'rows' | 'total'>
/** @deprecated Use streamedPreview instead */ /** Optional initial preview - if not provided, will be fetched via API */
initialPreview?: string[] initialPreview?: string[]
/** Suspense boundary containing the streaming preview */
streamedPreview?: ReactNode
} }
export function AdditionWorksheetClient({ export function AdditionWorksheetClient({
initialSettings, initialSettings,
initialPreview, initialPreview,
streamedPreview,
}: AdditionWorksheetClientProps) { }: AdditionWorksheetClientProps) {
const searchParams = useSearchParams() const searchParams = useSearchParams()
const isFromShare = searchParams.get('from') === 'share' const isFromShare = searchParams.get('from') === 'share'
@ -84,41 +80,36 @@ export function AdditionWorksheetClient({
return ( return (
<PageWithNav navTitle={t('navTitle')} navEmoji="📝"> <PageWithNav navTitle={t('navTitle')} navEmoji="📝">
<StreamedPreviewProvider> <WorksheetConfigProvider formState={formState} updateFormState={updateFormState}>
<WorksheetConfigProvider formState={formState} updateFormState={updateFormState}> <div
{/* Render the streamed preview Suspense boundary - it injects data into context */} data-component="addition-worksheet-page"
{streamedPreview} 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}
/>
}
/>
<div {/* Error Display */}
data-component="addition-worksheet-page" <GenerationErrorDisplay error={error} visible={status === 'error'} onRetry={reset} />
className={css({ </div>
height: '100vh', </WorksheetConfigProvider>
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> </PageWithNav>
) )
} }

View File

@ -12,7 +12,6 @@ import { extractConfigFields } from '../utils/extractConfigFields'
import { FloatingPageIndicator } from './FloatingPageIndicator' import { FloatingPageIndicator } from './FloatingPageIndicator'
import { LoadShareCodeModal } from './LoadShareCodeModal' import { LoadShareCodeModal } from './LoadShareCodeModal'
import { ShareModal } from './ShareModal' import { ShareModal } from './ShareModal'
import { useStreamedPreview } from './StreamedPreviewContext'
import { useWorksheetConfig } from './WorksheetConfigContext' import { useWorksheetConfig } from './WorksheetConfigContext'
import { WorksheetPreview } from './WorksheetPreview' import { WorksheetPreview } from './WorksheetPreview'
import { DuplicateWarningBanner } from './worksheet-preview/DuplicateWarningBanner' import { DuplicateWarningBanner } from './worksheet-preview/DuplicateWarningBanner'
@ -40,12 +39,8 @@ export function PreviewCenter({
const router = useRouter() const router = useRouter()
const { resolvedTheme } = useTheme() const { resolvedTheme } = useTheme()
const { onChange } = useWorksheetConfig() const { onChange } = useWorksheetConfig()
const { streamedPages, isLoaded: isStreamedLoaded } = useStreamedPreview()
const isDark = resolvedTheme === 'dark' const isDark = resolvedTheme === 'dark'
const scrollContainerRef = useRef<HTMLDivElement>(null) 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 [isScrolling, setIsScrolling] = useState(false)
const scrollTimeoutRef = useRef<NodeJS.Timeout>() const scrollTimeoutRef = useRef<NodeJS.Timeout>()
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false) const [isUploadModalOpen, setIsUploadModalOpen] = useState(false)
@ -587,7 +582,7 @@ export function PreviewCenter({
> >
<WorksheetPreview <WorksheetPreview
formState={formState} formState={formState}
initialData={effectivePreview} initialData={initialPreview}
isScrolling={isScrolling} isScrolling={isScrolling}
onPageDataReady={setPageData} onPageDataReady={setPageData}
/> />

View File

@ -1,12 +1,9 @@
import { Suspense } from 'react'
import { eq, and } from 'drizzle-orm' import { eq, and } from 'drizzle-orm'
import { db, schema } from '@/db' import { db, schema } from '@/db'
import { getViewerId } from '@/lib/viewer' import { getViewerId } from '@/lib/viewer'
import { parseAdditionConfig, defaultAdditionConfig } from '@/app/create/worksheets/config-schemas' import { parseAdditionConfig, defaultAdditionConfig } from '@/app/create/worksheets/config-schemas'
import { AdditionWorksheetClient } from './components/AdditionWorksheetClient' import { AdditionWorksheetClient } from './components/AdditionWorksheetClient'
import { WorksheetErrorBoundary } from './components/WorksheetErrorBoundary' import { WorksheetErrorBoundary } from './components/WorksheetErrorBoundary'
import { PreviewSkeleton } from './components/PreviewSkeleton'
import { StreamedPreview } from './components/StreamedPreview'
import type { WorksheetFormState } from '@/app/create/worksheets/types' import type { WorksheetFormState } from '@/app/create/worksheets/types'
/** /**
@ -76,32 +73,12 @@ async function loadWorksheetSettings(): Promise<
} }
/** /**
* Build full config from settings * Worksheet page - loads settings fast, preview fetched client-side
*/
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
return {
...settings,
rows,
total,
date: getDefaultDate(),
}
}
/**
* Worksheet page with Suspense streaming for preview
* *
* Architecture: * Performance optimization:
* 1. Settings load fast (~50ms) - page shell renders immediately * - Settings load fast (~50ms) - page shell renders immediately
* 2. Preview generates async (~500ms) and streams in via Suspense * - Preview is fetched via API after initial render (non-blocking)
* 3. User sees the page UI in ~200ms, preview appears when ready * - User sees the page UI in ~200ms, preview appears when API completes
*/ */
export default async function AdditionWorksheetPage() { export default async function AdditionWorksheetPage() {
const pageStart = Date.now() const pageStart = Date.now()
@ -110,19 +87,13 @@ export default async function AdditionWorksheetPage() {
const initialSettings = await loadWorksheetSettings() const initialSettings = await loadWorksheetSettings()
console.log(`[SSR] Settings loaded in ${Date.now() - pageStart}ms`) console.log(`[SSR] Settings loaded in ${Date.now() - pageStart}ms`)
// Build full config for preview generation // Page renders immediately - preview will be fetched client-side
const fullConfig = buildFullConfig(initialSettings) // This avoids embedding the 1.25MB SVG in the initial HTML
// Page shell renders immediately, preview streams in via Suspense
return ( return (
<WorksheetErrorBoundary> <WorksheetErrorBoundary>
<AdditionWorksheetClient <AdditionWorksheetClient
initialSettings={initialSettings} initialSettings={initialSettings}
streamedPreview={ // No initial preview - will be fetched via API
<Suspense fallback={<PreviewSkeleton />}>
<StreamedPreview config={fullConfig} />
</Suspense>
}
/> />
</WorksheetErrorBoundary> </WorksheetErrorBoundary>
) )