Skip to content

Commit c2ded1f

Browse files
authored
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
1 parent ff43528 commit c2ded1f

File tree

8 files changed

+175
-51
lines changed

8 files changed

+175
-51
lines changed

apps/sim/app/layout.tsx

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { createLogger } from '@/lib/logs/console/logger'
1010
import { getAssetUrl } from '@/lib/utils'
1111
import '@/app/globals.css'
1212

13+
import { ThemeProvider } from '@/app/theme-provider'
1314
import { ZoomPrevention } from '@/app/zoom-prevention'
1415

1516
const logger = createLogger('RootLayout')
@@ -45,11 +46,14 @@ if (typeof window !== 'undefined') {
4546
}
4647

4748
export const viewport: Viewport = {
48-
themeColor: '#ffffff',
4949
width: 'device-width',
5050
initialScale: 1,
5151
maximumScale: 1,
5252
userScalable: false,
53+
themeColor: [
54+
{ media: '(prefers-color-scheme: light)', color: '#ffffff' },
55+
{ media: '(prefers-color-scheme: dark)', color: '#0c0c0c' },
56+
],
5357
}
5458

5559
// Generate dynamic metadata based on brand configuration
@@ -70,8 +74,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
7074
/>
7175

7276
{/* Meta tags for better SEO */}
73-
<meta name='theme-color' content='#ffffff' />
74-
<meta name='color-scheme' content='light' />
77+
<meta name='color-scheme' content='light dark' />
7578
<meta name='format-detection' content='telephone=no' />
7679
<meta httpEquiv='x-ua-compatible' content='ie=edge' />
7780

@@ -107,16 +110,18 @@ export default function RootLayout({ children }: { children: React.ReactNode })
107110
)}
108111
</head>
109112
<body suppressHydrationWarning>
110-
<BrandedLayout>
111-
<ZoomPrevention />
112-
{children}
113-
{isHosted && (
114-
<>
115-
<SpeedInsights />
116-
<Analytics />
117-
</>
118-
)}
119-
</BrandedLayout>
113+
<ThemeProvider>
114+
<BrandedLayout>
115+
<ZoomPrevention />
116+
{children}
117+
{isHosted && (
118+
<>
119+
<SpeedInsights />
120+
<Analytics />
121+
</>
122+
)}
123+
</BrandedLayout>
124+
</ThemeProvider>
120125
</body>
121126
</html>
122127
)

apps/sim/app/theme-provider.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
'use client'
2+
3+
import type { ThemeProviderProps } from 'next-themes'
4+
import { ThemeProvider as NextThemesProvider } from 'next-themes'
5+
6+
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
7+
return (
8+
<NextThemesProvider
9+
attribute='class'
10+
defaultTheme='system'
11+
enableSystem
12+
disableTransitionOnChange
13+
storageKey='sim-theme'
14+
{...props}
15+
>
16+
{children}
17+
</NextThemesProvider>
18+
)
19+
}

apps/sim/app/workspace/[workspaceId]/providers/providers.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,21 @@
22

33
import React from 'react'
44
import { TooltipProvider } from '@/components/ui/tooltip'
5-
import { ThemeProvider } from '@/app/workspace/[workspaceId]/providers/theme-provider'
65
import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
6+
import { SettingsLoader } from './settings-loader'
77

88
interface ProvidersProps {
99
children: React.ReactNode
1010
}
1111

1212
const Providers = React.memo<ProvidersProps>(({ children }) => {
1313
return (
14-
<ThemeProvider>
14+
<>
15+
<SettingsLoader />
1516
<TooltipProvider delayDuration={100} skipDelayDuration={0}>
1617
<WorkspacePermissionsProvider>{children}</WorkspacePermissionsProvider>
1718
</TooltipProvider>
18-
</ThemeProvider>
19+
</>
1920
)
2021
})
2122

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
'use client'
2+
3+
import { useEffect, useRef } from 'react'
4+
import { useSession } from '@/lib/auth-client'
5+
import { useGeneralStore } from '@/stores/settings/general/store'
6+
7+
/**
8+
* Loads user settings from database once per workspace session.
9+
* This ensures settings are synced from DB on initial load but uses
10+
* localStorage cache for subsequent navigation within the app.
11+
*/
12+
export function SettingsLoader() {
13+
const { data: session, isPending: isSessionPending } = useSession()
14+
const loadSettings = useGeneralStore((state) => state.loadSettings)
15+
const hasLoadedRef = useRef(false)
16+
17+
useEffect(() => {
18+
// Only load settings once per session for authenticated users
19+
if (!isSessionPending && session?.user && !hasLoadedRef.current) {
20+
hasLoadedRef.current = true
21+
// Force load from DB on initial workspace entry
22+
loadSettings(true)
23+
}
24+
}, [isSessionPending, session?.user, loadSettings])
25+
26+
return null
27+
}

apps/sim/app/workspace/[workspaceId]/providers/theme-provider.tsx

Lines changed: 0 additions & 23 deletions
This file was deleted.

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/general/general.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,15 @@ export function General() {
4545
const toggleConsoleExpandedByDefault = useGeneralStore(
4646
(state) => state.toggleConsoleExpandedByDefault
4747
)
48-
const loadSettings = useGeneralStore((state) => state.loadSettings)
4948

49+
// Sync theme from store to next-themes when theme changes
5050
useEffect(() => {
51-
const loadData = async () => {
52-
await loadSettings()
51+
if (!isLoading && theme) {
52+
// Ensure next-themes is in sync with our store
53+
const { syncThemeToNextThemes } = require('@/lib/theme-sync')
54+
syncThemeToNextThemes(theme)
5355
}
54-
loadData()
55-
}, [loadSettings])
56+
}, [theme, isLoading])
5657

5758
const handleThemeChange = async (value: 'system' | 'light' | 'dark') => {
5859
await setTheme(value)

apps/sim/lib/theme-sync.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* Theme synchronization utilities for managing theme across next-themes and database
3+
*/
4+
5+
/**
6+
* Updates the theme in next-themes by dispatching a storage event
7+
* This works by updating localStorage and notifying next-themes of the change
8+
*/
9+
export function syncThemeToNextThemes(theme: 'system' | 'light' | 'dark') {
10+
if (typeof window === 'undefined') return
11+
12+
// Update localStorage
13+
localStorage.setItem('sim-theme', theme)
14+
15+
// Dispatch storage event to notify next-themes
16+
window.dispatchEvent(
17+
new StorageEvent('storage', {
18+
key: 'sim-theme',
19+
newValue: theme,
20+
oldValue: localStorage.getItem('sim-theme'),
21+
storageArea: localStorage,
22+
url: window.location.href,
23+
})
24+
)
25+
26+
// Also update the HTML class immediately for instant feedback
27+
const root = document.documentElement
28+
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
29+
const actualTheme = theme === 'system' ? systemTheme : theme
30+
31+
// Remove existing theme classes
32+
root.classList.remove('light', 'dark')
33+
// Add new theme class
34+
root.classList.add(actualTheme)
35+
}
36+
37+
/**
38+
* Gets the current theme from next-themes localStorage
39+
*/
40+
export function getThemeFromNextThemes(): 'system' | 'light' | 'dark' {
41+
if (typeof window === 'undefined') return 'system'
42+
return (localStorage.getItem('sim-theme') as 'system' | 'light' | 'dark') || 'system'
43+
}

apps/sim/stores/settings/general/store.ts

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { create } from 'zustand'
22
import { devtools, persist } from 'zustand/middleware'
33
import { createLogger } from '@/lib/logs/console/logger'
4+
import { syncThemeToNextThemes } from '@/lib/theme-sync'
45
import type { General, GeneralStore, UserSettings } from '@/stores/settings/general/types'
56

67
const logger = createLogger('GeneralStore')
78

8-
const CACHE_TIMEOUT = 5000
9+
const CACHE_TIMEOUT = 3600000 // 1 hour - settings rarely change
910
const MAX_ERROR_RETRIES = 2
1011

1112
export const useGeneralStore = create<GeneralStore>()(
@@ -14,21 +15,22 @@ export const useGeneralStore = create<GeneralStore>()(
1415
(set, get) => {
1516
let lastLoadTime = 0
1617
let errorRetryCount = 0
18+
let hasLoadedFromDb = false // Track if we've loaded from DB in this session
1719

1820
const store: General = {
1921
isAutoConnectEnabled: true,
2022
isAutoPanEnabled: true,
2123
isConsoleExpandedByDefault: true,
2224
isDebugModeEnabled: false,
23-
theme: 'system' as const,
25+
theme: 'system' as const, // Keep for compatibility but not used
2426
telemetryEnabled: true,
2527
isLoading: false,
2628
error: null,
2729
// Individual loading states
2830
isAutoConnectLoading: false,
2931
isAutoPanLoading: false,
3032
isConsoleExpandedByDefaultLoading: false,
31-
isThemeLoading: false,
33+
isThemeLoading: false, // Keep for compatibility but not used
3234
isTelemetryLoading: false,
3335
}
3436

@@ -99,7 +101,26 @@ export const useGeneralStore = create<GeneralStore>()(
99101

100102
setTheme: async (theme) => {
101103
if (get().isThemeLoading) return
102-
await updateSettingOptimistic('theme', theme, 'isThemeLoading', 'theme')
104+
105+
const originalTheme = get().theme
106+
107+
// Optimistic update
108+
set({ theme, isThemeLoading: true })
109+
110+
// Update next-themes immediately for instant feedback
111+
syncThemeToNextThemes(theme)
112+
113+
try {
114+
// Sync to DB for authenticated users
115+
await get().updateSetting('theme', theme)
116+
set({ isThemeLoading: false })
117+
} catch (error) {
118+
// Rollback on error
119+
set({ theme: originalTheme, isThemeLoading: false })
120+
syncThemeToNextThemes(originalTheme)
121+
logger.error('Failed to sync theme to database:', error)
122+
throw error
123+
}
103124
},
104125

105126
setTelemetryEnabled: async (enabled) => {
@@ -114,6 +135,27 @@ export const useGeneralStore = create<GeneralStore>()(
114135

115136
// API Actions
116137
loadSettings: async (force = false) => {
138+
// Skip if we've already loaded from DB and not forcing
139+
if (hasLoadedFromDb && !force) {
140+
logger.debug('Already loaded settings from DB, using cached data')
141+
return
142+
}
143+
144+
// If we have persisted state and not forcing, check if we need to load
145+
const persistedState = localStorage.getItem('general-settings')
146+
if (persistedState && !force) {
147+
try {
148+
const parsed = JSON.parse(persistedState)
149+
// If we have valid theme data, skip DB load unless forced
150+
if (parsed.state?.theme) {
151+
logger.debug('Using cached settings from localStorage')
152+
hasLoadedFromDb = true // Mark as loaded to prevent future API calls
153+
return
154+
}
155+
} catch (e) {
156+
// If parsing fails, continue to load from DB
157+
}
158+
}
117159
// Skip loading if on a subdomain or chat path
118160
if (
119161
typeof window !== 'undefined' &&
@@ -147,15 +189,24 @@ export const useGeneralStore = create<GeneralStore>()(
147189

148190
set({
149191
isAutoConnectEnabled: data.autoConnect,
150-
isAutoPanEnabled: data.autoPan ?? true, // Default to true if undefined
151-
isConsoleExpandedByDefault: data.consoleExpandedByDefault ?? true, // Default to true if undefined
152-
theme: data.theme,
192+
isAutoPanEnabled: data.autoPan ?? true,
193+
isConsoleExpandedByDefault: data.consoleExpandedByDefault ?? true,
194+
theme: data.theme || 'system',
153195
telemetryEnabled: data.telemetryEnabled,
154196
isLoading: false,
155197
})
156198

199+
// Sync theme to next-themes if it's different
200+
if (data.theme && typeof window !== 'undefined') {
201+
const currentTheme = localStorage.getItem('sim-theme')
202+
if (currentTheme !== data.theme) {
203+
syncThemeToNextThemes(data.theme)
204+
}
205+
}
206+
157207
lastLoadTime = now
158208
errorRetryCount = 0
209+
hasLoadedFromDb = true
159210
} catch (error) {
160211
logger.error('Error loading settings:', error)
161212
set({

0 commit comments

Comments
 (0)