From 2b5d66f7764bcccb80a48453cae1ae50973cdaef Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Fri, 23 Jan 2026 18:54:17 -0600 Subject: [PATCH] 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 --- .claude/settings.local.json | 6 +- apps/web/.claude/settings.local.json | 10 ++- .../components/AdditionWorksheetClient.tsx | 70 ++++++++------- .../worksheets/components/PreviewCenter.tsx | 7 +- .../components/PreviewDataInjector.tsx | 31 +++++++ .../worksheets/components/PreviewSkeleton.tsx | 85 +++++++++++++++++++ .../worksheets/components/StreamedPreview.tsx | 37 ++++++++ .../components/StreamedPreviewContext.tsx | 77 +++++++++++++++++ apps/web/src/app/create/worksheets/page.tsx | 75 ++++++++-------- 9 files changed, 328 insertions(+), 70 deletions(-) create mode 100644 apps/web/src/app/create/worksheets/components/PreviewDataInjector.tsx create mode 100644 apps/web/src/app/create/worksheets/components/PreviewSkeleton.tsx create mode 100644 apps/web/src/app/create/worksheets/components/StreamedPreview.tsx create mode 100644 apps/web/src/app/create/worksheets/components/StreamedPreviewContext.tsx diff --git a/.claude/settings.local.json b/.claude/settings.local.json index b722c1ce..a249b590 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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": [] diff --git a/apps/web/.claude/settings.local.json b/apps/web/.claude/settings.local.json index 42541cba..f11153e5 100644 --- a/apps/web/.claude/settings.local.json +++ b/apps/web/.claude/settings.local.json @@ -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": [] diff --git a/apps/web/src/app/create/worksheets/components/AdditionWorksheetClient.tsx b/apps/web/src/app/create/worksheets/components/AdditionWorksheetClient.tsx index a9f2481c..54a165f0 100644 --- a/apps/web/src/app/create/worksheets/components/AdditionWorksheetClient.tsx +++ b/apps/web/src/app/create/worksheets/components/AdditionWorksheetClient.tsx @@ -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 + /** @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 ( - -
- {/* Responsive Panel Layout (desktop) or Drawer (mobile) */} - } - previewContent={ - - } - /> + + + {/* Render the streamed preview Suspense boundary - it injects data into context */} + {streamedPreview} - {/* Error Display */} - -
-
+
+ {/* Responsive Panel Layout (desktop) or Drawer (mobile) */} + } + previewContent={ + + } + /> + + {/* Error Display */} + +
+ +
) } diff --git a/apps/web/src/app/create/worksheets/components/PreviewCenter.tsx b/apps/web/src/app/create/worksheets/components/PreviewCenter.tsx index 32796da7..62abce5d 100644 --- a/apps/web/src/app/create/worksheets/components/PreviewCenter.tsx +++ b/apps/web/src/app/create/worksheets/components/PreviewCenter.tsx @@ -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(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() const [isUploadModalOpen, setIsUploadModalOpen] = useState(false) @@ -582,7 +587,7 @@ export function PreviewCenter({ > diff --git a/apps/web/src/app/create/worksheets/components/PreviewDataInjector.tsx b/apps/web/src/app/create/worksheets/components/PreviewDataInjector.tsx new file mode 100644 index 00000000..9c3038ce --- /dev/null +++ b/apps/web/src/app/create/worksheets/components/PreviewDataInjector.tsx @@ -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 +} diff --git a/apps/web/src/app/create/worksheets/components/PreviewSkeleton.tsx b/apps/web/src/app/create/worksheets/components/PreviewSkeleton.tsx new file mode 100644 index 00000000..f677c99c --- /dev/null +++ b/apps/web/src/app/create/worksheets/components/PreviewSkeleton.tsx @@ -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 ( +
+ {/* Worksheet page placeholder */} +
+ + {/* Loading text */} +
+ + Generating preview... +
+
+ ) +} + +function LoadingSpinner() { + return ( + + + + + ) +} diff --git a/apps/web/src/app/create/worksheets/components/StreamedPreview.tsx b/apps/web/src/app/create/worksheets/components/StreamedPreview.tsx new file mode 100644 index 00000000..27033651 --- /dev/null +++ b/apps/web/src/app/create/worksheets/components/StreamedPreview.tsx @@ -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 ( + + ) +} diff --git a/apps/web/src/app/create/worksheets/components/StreamedPreviewContext.tsx b/apps/web/src/app/create/worksheets/components/StreamedPreviewContext.tsx new file mode 100644 index 00000000..dbf9eb5e --- /dev/null +++ b/apps/web/src/app/create/worksheets/components/StreamedPreviewContext.tsx @@ -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(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(undefined) + const [totalPages, setTotalPages] = useState(undefined) + const [isLoaded, setIsLoaded] = useState(false) + + const setStreamedPreview = useCallback( + (pages: string[] | undefined, total: number | undefined) => { + setStreamedPages(pages) + setTotalPages(total) + setIsLoaded(true) + }, + [] + ) + + return ( + + {children} + + ) +} + +/** + * 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 +} diff --git a/apps/web/src/app/create/worksheets/page.tsx b/apps/web/src/app/create/worksheets/page.tsx index 925766c2..178d4c59 100644 --- a/apps/web/src/app/create/worksheets/page.tsx +++ b/apps/web/src/app/create/worksheets/page.tsx @@ -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 { + 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 ( }> + + + } /> )