From 30fb0e86e367d8da9c2bfaebf2d5b4467c8e259a Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Fri, 23 Jan 2026 19:29:59 -0600 Subject: [PATCH] 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 --- apps/web/.claude/settings.local.json | 3 +- .../components/AdditionWorksheetClient.tsx | 71 ++++++++----------- .../worksheets/components/PreviewCenter.tsx | 7 +- apps/web/src/app/create/worksheets/page.tsx | 45 +++--------- 4 files changed, 42 insertions(+), 84 deletions(-) diff --git a/apps/web/.claude/settings.local.json b/apps/web/.claude/settings.local.json index f11153e5..2b0d7678 100644 --- a/apps/web/.claude/settings.local.json +++ b/apps/web/.claude/settings.local.json @@ -127,7 +127,8 @@ "WebFetch(domain:raw.githubusercontent.com)", "Bash(helm get values:*)", "Bash(kubectl set:*)", - "Bash(kubectl annotate:*)" + "Bash(kubectl annotate:*)", + "Bash(kubectl run:*)" ], "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 54a165f0..f43d30f3 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, type ReactNode } from 'react' +import { useEffect, useState } from 'react' import type { WorksheetFormState } from '@/app/create/worksheets/types' import { PageWithNav } from '@/components/PageWithNav' import { useTheme } from '@/contexts/ThemeContext' @@ -15,21 +15,17 @@ 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 */ + /** Optional initial preview - if not provided, will be fetched via API */ 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' @@ -84,41 +80,36 @@ export function AdditionWorksheetClient({ return ( - - - {/* Render the streamed preview Suspense boundary - it injects data into context */} - {streamedPreview} + +
+ {/* Responsive Panel Layout (desktop) or Drawer (mobile) */} + } + previewContent={ + + } + /> -
- {/* Responsive Panel Layout (desktop) or Drawer (mobile) */} - } - previewContent={ - - } - /> - - {/* Error Display */} - -
- - + {/* 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 62abce5d..32796da7 100644 --- a/apps/web/src/app/create/worksheets/components/PreviewCenter.tsx +++ b/apps/web/src/app/create/worksheets/components/PreviewCenter.tsx @@ -12,7 +12,6 @@ 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' @@ -40,12 +39,8 @@ 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) @@ -587,7 +582,7 @@ export function PreviewCenter({ > diff --git a/apps/web/src/app/create/worksheets/page.tsx b/apps/web/src/app/create/worksheets/page.tsx index 178d4c59..c8226114 100644 --- a/apps/web/src/app/create/worksheets/page.tsx +++ b/apps/web/src/app/create/worksheets/page.tsx @@ -1,12 +1,9 @@ -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' /** @@ -76,32 +73,12 @@ async function loadWorksheetSettings(): Promise< } /** - * 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 - - return { - ...settings, - rows, - total, - date: getDefaultDate(), - } -} - -/** - * Worksheet page with Suspense streaming for preview + * Worksheet page - loads settings fast, preview fetched client-side * - * 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 + * Performance optimization: + * - Settings load fast (~50ms) - page shell renders immediately + * - Preview is fetched via API after initial render (non-blocking) + * - User sees the page UI in ~200ms, preview appears when API completes */ export default async function AdditionWorksheetPage() { const pageStart = Date.now() @@ -110,19 +87,13 @@ export default async function AdditionWorksheetPage() { 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 + // Page renders immediately - preview will be fetched client-side + // This avoids embedding the 1.25MB SVG in the initial HTML return ( }> - - - } + // No initial preview - will be fetched via API /> )