fix(theme-provider): preventing flash on page load (#1067)

* fix(theme-provider): preventing flash on page load

* consolidated themes to use NextJS theme logic

* improvement: optimized latency
This commit is contained in:
Emir Karabeg
2025-08-20 20:20:23 -07:00
committed by GitHub
parent ff43528d35
commit c2ded1f3e1
8 changed files with 175 additions and 51 deletions
+18 -13
View File
@@ -10,6 +10,7 @@ import { createLogger } from '@/lib/logs/console/logger'
import { getAssetUrl } from '@/lib/utils'
import '@/app/globals.css'
import { ThemeProvider } from '@/app/theme-provider'
import { ZoomPrevention } from '@/app/zoom-prevention'
const logger = createLogger('RootLayout')
@@ -45,11 +46,14 @@ if (typeof window !== 'undefined') {
}
export const viewport: Viewport = {
themeColor: '#ffffff',
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false,
themeColor: [
{ media: '(prefers-color-scheme: light)', color: '#ffffff' },
{ media: '(prefers-color-scheme: dark)', color: '#0c0c0c' },
],
}
// Generate dynamic metadata based on brand configuration
@@ -70,8 +74,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
/>
{/* Meta tags for better SEO */}
<meta name='theme-color' content='#ffffff' />
<meta name='color-scheme' content='light' />
<meta name='color-scheme' content='light dark' />
<meta name='format-detection' content='telephone=no' />
<meta httpEquiv='x-ua-compatible' content='ie=edge' />
@@ -107,16 +110,18 @@ export default function RootLayout({ children }: { children: React.ReactNode })
)}
</head>
<body suppressHydrationWarning>
<BrandedLayout>
<ZoomPrevention />
{children}
{isHosted && (
<>
<SpeedInsights />
<Analytics />
</>
)}
</BrandedLayout>
<ThemeProvider>
<BrandedLayout>
<ZoomPrevention />
{children}
{isHosted && (
<>
<SpeedInsights />
<Analytics />
</>
)}
</BrandedLayout>
</ThemeProvider>
</body>
</html>
)
+19
View File
@@ -0,0 +1,19 @@
'use client'
import type { ThemeProviderProps } from 'next-themes'
import { ThemeProvider as NextThemesProvider } from 'next-themes'
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return (
<NextThemesProvider
attribute='class'
defaultTheme='system'
enableSystem
disableTransitionOnChange
storageKey='sim-theme'
{...props}
>
{children}
</NextThemesProvider>
)
}
@@ -2,8 +2,8 @@
import React from 'react'
import { TooltipProvider } from '@/components/ui/tooltip'
import { ThemeProvider } from '@/app/workspace/[workspaceId]/providers/theme-provider'
import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { SettingsLoader } from './settings-loader'
interface ProvidersProps {
children: React.ReactNode
@@ -11,11 +11,12 @@ interface ProvidersProps {
const Providers = React.memo<ProvidersProps>(({ children }) => {
return (
<ThemeProvider>
<>
<SettingsLoader />
<TooltipProvider delayDuration={100} skipDelayDuration={0}>
<WorkspacePermissionsProvider>{children}</WorkspacePermissionsProvider>
</TooltipProvider>
</ThemeProvider>
</>
)
})
@@ -0,0 +1,27 @@
'use client'
import { useEffect, useRef } from 'react'
import { useSession } from '@/lib/auth-client'
import { useGeneralStore } from '@/stores/settings/general/store'
/**
* Loads user settings from database once per workspace session.
* This ensures settings are synced from DB on initial load but uses
* localStorage cache for subsequent navigation within the app.
*/
export function SettingsLoader() {
const { data: session, isPending: isSessionPending } = useSession()
const loadSettings = useGeneralStore((state) => state.loadSettings)
const hasLoadedRef = useRef(false)
useEffect(() => {
// Only load settings once per session for authenticated users
if (!isSessionPending && session?.user && !hasLoadedRef.current) {
hasLoadedRef.current = true
// Force load from DB on initial workspace entry
loadSettings(true)
}
}, [isSessionPending, session?.user, loadSettings])
return null
}
@@ -1,23 +0,0 @@
'use client'
import { useEffect } from 'react'
import { useGeneralStore } from '@/stores/settings/general/store'
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const theme = useGeneralStore((state) => state.theme)
useEffect(() => {
const root = window.document.documentElement
root.classList.remove('light', 'dark')
// If theme is system, check system preference
if (theme === 'system') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
root.classList.add(prefersDark ? 'dark' : 'light')
} else {
root.classList.add(theme)
}
}, [theme])
return children
}
@@ -45,14 +45,15 @@ export function General() {
const toggleConsoleExpandedByDefault = useGeneralStore(
(state) => state.toggleConsoleExpandedByDefault
)
const loadSettings = useGeneralStore((state) => state.loadSettings)
// Sync theme from store to next-themes when theme changes
useEffect(() => {
const loadData = async () => {
await loadSettings()
if (!isLoading && theme) {
// Ensure next-themes is in sync with our store
const { syncThemeToNextThemes } = require('@/lib/theme-sync')
syncThemeToNextThemes(theme)
}
loadData()
}, [loadSettings])
}, [theme, isLoading])
const handleThemeChange = async (value: 'system' | 'light' | 'dark') => {
await setTheme(value)
+43
View File
@@ -0,0 +1,43 @@
/**
* Theme synchronization utilities for managing theme across next-themes and database
*/
/**
* Updates the theme in next-themes by dispatching a storage event
* This works by updating localStorage and notifying next-themes of the change
*/
export function syncThemeToNextThemes(theme: 'system' | 'light' | 'dark') {
if (typeof window === 'undefined') return
// Update localStorage
localStorage.setItem('sim-theme', theme)
// Dispatch storage event to notify next-themes
window.dispatchEvent(
new StorageEvent('storage', {
key: 'sim-theme',
newValue: theme,
oldValue: localStorage.getItem('sim-theme'),
storageArea: localStorage,
url: window.location.href,
})
)
// Also update the HTML class immediately for instant feedback
const root = document.documentElement
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
const actualTheme = theme === 'system' ? systemTheme : theme
// Remove existing theme classes
root.classList.remove('light', 'dark')
// Add new theme class
root.classList.add(actualTheme)
}
/**
* Gets the current theme from next-themes localStorage
*/
export function getThemeFromNextThemes(): 'system' | 'light' | 'dark' {
if (typeof window === 'undefined') return 'system'
return (localStorage.getItem('sim-theme') as 'system' | 'light' | 'dark') || 'system'
}
+58 -7
View File
@@ -1,11 +1,12 @@
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { createLogger } from '@/lib/logs/console/logger'
import { syncThemeToNextThemes } from '@/lib/theme-sync'
import type { General, GeneralStore, UserSettings } from '@/stores/settings/general/types'
const logger = createLogger('GeneralStore')
const CACHE_TIMEOUT = 5000
const CACHE_TIMEOUT = 3600000 // 1 hour - settings rarely change
const MAX_ERROR_RETRIES = 2
export const useGeneralStore = create<GeneralStore>()(
@@ -14,13 +15,14 @@ export const useGeneralStore = create<GeneralStore>()(
(set, get) => {
let lastLoadTime = 0
let errorRetryCount = 0
let hasLoadedFromDb = false // Track if we've loaded from DB in this session
const store: General = {
isAutoConnectEnabled: true,
isAutoPanEnabled: true,
isConsoleExpandedByDefault: true,
isDebugModeEnabled: false,
theme: 'system' as const,
theme: 'system' as const, // Keep for compatibility but not used
telemetryEnabled: true,
isLoading: false,
error: null,
@@ -28,7 +30,7 @@ export const useGeneralStore = create<GeneralStore>()(
isAutoConnectLoading: false,
isAutoPanLoading: false,
isConsoleExpandedByDefaultLoading: false,
isThemeLoading: false,
isThemeLoading: false, // Keep for compatibility but not used
isTelemetryLoading: false,
}
@@ -99,7 +101,26 @@ export const useGeneralStore = create<GeneralStore>()(
setTheme: async (theme) => {
if (get().isThemeLoading) return
await updateSettingOptimistic('theme', theme, 'isThemeLoading', 'theme')
const originalTheme = get().theme
// Optimistic update
set({ theme, isThemeLoading: true })
// Update next-themes immediately for instant feedback
syncThemeToNextThemes(theme)
try {
// Sync to DB for authenticated users
await get().updateSetting('theme', theme)
set({ isThemeLoading: false })
} catch (error) {
// Rollback on error
set({ theme: originalTheme, isThemeLoading: false })
syncThemeToNextThemes(originalTheme)
logger.error('Failed to sync theme to database:', error)
throw error
}
},
setTelemetryEnabled: async (enabled) => {
@@ -114,6 +135,27 @@ export const useGeneralStore = create<GeneralStore>()(
// API Actions
loadSettings: async (force = false) => {
// Skip if we've already loaded from DB and not forcing
if (hasLoadedFromDb && !force) {
logger.debug('Already loaded settings from DB, using cached data')
return
}
// If we have persisted state and not forcing, check if we need to load
const persistedState = localStorage.getItem('general-settings')
if (persistedState && !force) {
try {
const parsed = JSON.parse(persistedState)
// If we have valid theme data, skip DB load unless forced
if (parsed.state?.theme) {
logger.debug('Using cached settings from localStorage')
hasLoadedFromDb = true // Mark as loaded to prevent future API calls
return
}
} catch (e) {
// If parsing fails, continue to load from DB
}
}
// Skip loading if on a subdomain or chat path
if (
typeof window !== 'undefined' &&
@@ -147,15 +189,24 @@ export const useGeneralStore = create<GeneralStore>()(
set({
isAutoConnectEnabled: data.autoConnect,
isAutoPanEnabled: data.autoPan ?? true, // Default to true if undefined
isConsoleExpandedByDefault: data.consoleExpandedByDefault ?? true, // Default to true if undefined
theme: data.theme,
isAutoPanEnabled: data.autoPan ?? true,
isConsoleExpandedByDefault: data.consoleExpandedByDefault ?? true,
theme: data.theme || 'system',
telemetryEnabled: data.telemetryEnabled,
isLoading: false,
})
// Sync theme to next-themes if it's different
if (data.theme && typeof window !== 'undefined') {
const currentTheme = localStorage.getItem('sim-theme')
if (currentTheme !== data.theme) {
syncThemeToNextThemes(data.theme)
}
}
lastLoadTime = now
errorRetryCount = 0
hasLoadedFromDb = true
} catch (error) {
logger.error('Error loading settings:', error)
set({