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:
parent
ba08409269
commit
30fb0e86e3
|
|
@ -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": []
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue