perf: eliminate loading flash with delayed loading state

- Implement timeout-based loading state (100ms delay)
- Only show loading spinner for genuinely slow operations
- Cancel timeout if generation completes quickly
- Prevent flash for cached/fast renders while preserving UX for slow ones

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-09-15 09:22:48 -05:00
parent 39b6e5a20f
commit c70a390dc6

View File

@@ -1,6 +1,6 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import { generateSorobanSVG, getWasmStatus, triggerWasmPreload, type SorobanConfig } from '@/lib/typst-soroban'
import { css } from '../../styled-system/css'
import { useAbacusConfig } from '@/contexts/AbacusDisplayContext'
@@ -33,11 +33,60 @@ export function TypstSoroban({
const globalConfig = useAbacusConfig()
const abortControllerRef = useRef<AbortController | null>(null)
const currentConfigRef = useRef<any>(null)
// Memoize the config to prevent unnecessary re-renders
const stableConfig = useMemo(() => ({
beadShape: globalConfig.beadShape,
colorScheme: globalConfig.colorScheme,
hideInactiveBeads: globalConfig.hideInactiveBeads,
coloredNumerals: globalConfig.coloredNumerals,
scaleFactor: globalConfig.scaleFactor
}), [
globalConfig.beadShape,
globalConfig.colorScheme,
globalConfig.hideInactiveBeads,
globalConfig.coloredNumerals,
globalConfig.scaleFactor
])
useEffect(() => {
if (!shouldLoad) return
console.log(`🔄 TypstSoroban useEffect triggered for number ${number}, hasExistingSVG: ${!!svg}`)
async function generateSVG() {
// Create current config signature
const currentConfig = {
number,
width,
height,
beadShape: stableConfig.beadShape,
colorScheme: stableConfig.colorScheme,
hideInactiveBeads: stableConfig.hideInactiveBeads,
coloredNumerals: stableConfig.coloredNumerals,
scaleFactor: stableConfig.scaleFactor,
enableServerFallback
}
// Check if config changed since last render
const configChanged = JSON.stringify(currentConfig) !== JSON.stringify(currentConfigRef.current)
// Don't regenerate if we already have an SVG for this exact config
if (svg && !error && !configChanged) {
console.log(`✅ Skipping regeneration for ${number} - already have SVG with same config`)
return
}
if (configChanged) {
console.log(`🔄 Config changed for ${number}, regenerating SVG`)
// Clear existing SVG to show fresh loading state for config changes
setSvg(null)
}
// Update config ref
currentConfigRef.current = currentConfig
// Cancel any previous generation
if (abortControllerRef.current) {
abortControllerRef.current.abort()
@@ -46,34 +95,51 @@ export function TypstSoroban({
abortControllerRef.current = new AbortController()
const signal = abortControllerRef.current.signal
setIsLoading(true)
// Check cache quickly before showing loading state
const cacheKey = JSON.stringify({
number,
width,
height,
beadShape: stableConfig.beadShape,
colorScheme: stableConfig.colorScheme,
hideInactiveBeads: stableConfig.hideInactiveBeads,
coloredNumerals: stableConfig.coloredNumerals,
scaleFactor: stableConfig.scaleFactor,
enableServerFallback
})
setError(null)
// Longer delay to prevent flashing for fast renders
await new Promise(resolve => setTimeout(resolve, 300))
if (signal.aborted) return
try {
// Try generation immediately to see if it's cached
const config: SorobanConfig = {
number,
width,
height,
beadShape: globalConfig.beadShape,
colorScheme: globalConfig.colorScheme,
hideInactiveBeads: globalConfig.hideInactiveBeads,
coloredNumerals: globalConfig.coloredNumerals,
scaleFactor: globalConfig.scaleFactor,
beadShape: stableConfig.beadShape,
colorScheme: stableConfig.colorScheme,
hideInactiveBeads: stableConfig.hideInactiveBeads,
coloredNumerals: stableConfig.coloredNumerals,
scaleFactor: stableConfig.scaleFactor,
transparent: false,
enableServerFallback
}
// Set loading only after a delay if generation is slow
const loadingTimeout = setTimeout(() => {
if (!signal.aborted) {
setIsLoading(true)
}
}, 100)
const generatedSvg = await generateSorobanSVG(config)
// Clear timeout since we got result quickly
clearTimeout(loadingTimeout)
if (signal.aborted) return
setSvg(generatedSvg)
// Call success callback after state is set
setTimeout(() => onSuccess?.(), 0)
} catch (err) {
if (signal.aborted) return
@@ -81,7 +147,6 @@ export function TypstSoroban({
const errorMessage = err instanceof Error ? err.message : 'Unknown error'
setError(errorMessage)
console.error('TypstSoroban generation error:', err)
// Call error callback after state is set
setTimeout(() => onError?.(errorMessage), 0)
} finally {
if (!signal.aborted) {
@@ -97,7 +162,7 @@ export function TypstSoroban({
abortControllerRef.current.abort()
}
}
}, [shouldLoad, number, width, height, globalConfig.beadShape, globalConfig.colorScheme, globalConfig.hideInactiveBeads, globalConfig.coloredNumerals, globalConfig.scaleFactor, enableServerFallback])
}, [shouldLoad, number, width, height, stableConfig, enableServerFallback])
// Handler to trigger loading on user interaction
const handleLoadTrigger = useCallback(() => {