mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
+18
-13
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+6
-5
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user