fix: eliminate dark mode flicker by moving ThemeProvider to root level (#22996)
This commit is contained in:
445
web/__tests__/real-browser-flicker.test.tsx
Normal file
445
web/__tests__/real-browser-flicker.test.tsx
Normal file
@@ -0,0 +1,445 @@
|
||||
/**
|
||||
* Real Browser Environment Dark Mode Flicker Test
|
||||
*
|
||||
* This test attempts to simulate real browser refresh scenarios including:
|
||||
* 1. SSR HTML generation phase
|
||||
* 2. Client-side JavaScript loading
|
||||
* 3. Theme system initialization
|
||||
* 4. CSS styles application timing
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
// Setup browser environment for testing
|
||||
const setupMockEnvironment = (storedTheme: string | null, systemPrefersDark = false) => {
|
||||
// Mock localStorage
|
||||
const mockStorage = {
|
||||
getItem: jest.fn((key: string) => {
|
||||
if (key === 'theme') return storedTheme
|
||||
return null
|
||||
}),
|
||||
setItem: jest.fn(),
|
||||
removeItem: jest.fn(),
|
||||
}
|
||||
|
||||
// Mock system theme preference
|
||||
const mockMatchMedia = jest.fn((query: string) => ({
|
||||
matches: query.includes('dark') && systemPrefersDark,
|
||||
media: query,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
}))
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: mockStorage,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
value: mockMatchMedia,
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
|
||||
return { mockStorage, mockMatchMedia }
|
||||
}
|
||||
|
||||
// Simulate real page component based on Dify's actual theme usage
|
||||
const PageComponent = () => {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const { theme } = useTheme()
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
// Simulate common theme usage pattern in Dify
|
||||
const isDark = mounted ? theme === 'dark' : false
|
||||
|
||||
return (
|
||||
<div data-theme={isDark ? 'dark' : 'light'}>
|
||||
<div
|
||||
data-testid="page-content"
|
||||
style={{ backgroundColor: isDark ? '#1f2937' : '#ffffff' }}
|
||||
>
|
||||
<h1 style={{ color: isDark ? '#ffffff' : '#000000' }}>
|
||||
Dify Application
|
||||
</h1>
|
||||
<div data-testid="theme-indicator">
|
||||
Current Theme: {mounted ? theme : 'unknown'}
|
||||
</div>
|
||||
<div data-testid="visual-appearance">
|
||||
Appearance: {isDark ? 'dark' : 'light'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TestThemeProvider = ({ children }: { children: React.ReactNode }) => (
|
||||
<ThemeProvider
|
||||
attribute="data-theme"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
enableColorScheme={false}
|
||||
>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
)
|
||||
|
||||
describe('Real Browser Environment Dark Mode Flicker Test', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Page Refresh Scenario Simulation', () => {
|
||||
test('simulates complete page loading process with dark theme', async () => {
|
||||
// Setup: User previously selected dark mode
|
||||
setupMockEnvironment('dark')
|
||||
|
||||
render(
|
||||
<TestThemeProvider>
|
||||
<PageComponent />
|
||||
</TestThemeProvider>,
|
||||
)
|
||||
|
||||
// Check initial client-side rendering state
|
||||
const initialState = {
|
||||
theme: screen.getByTestId('theme-indicator').textContent,
|
||||
appearance: screen.getByTestId('visual-appearance').textContent,
|
||||
}
|
||||
console.log('Initial client state:', initialState)
|
||||
|
||||
// Wait for theme system to fully initialize
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('theme-indicator')).toHaveTextContent('Current Theme: dark')
|
||||
})
|
||||
|
||||
const finalState = {
|
||||
theme: screen.getByTestId('theme-indicator').textContent,
|
||||
appearance: screen.getByTestId('visual-appearance').textContent,
|
||||
}
|
||||
console.log('Final state:', finalState)
|
||||
|
||||
// Document the state change - this is the source of flicker
|
||||
console.log('State change detection: Initial -> Final')
|
||||
})
|
||||
|
||||
test('handles light theme correctly', async () => {
|
||||
setupMockEnvironment('light')
|
||||
|
||||
render(
|
||||
<TestThemeProvider>
|
||||
<PageComponent />
|
||||
</TestThemeProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('theme-indicator')).toHaveTextContent('Current Theme: light')
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('visual-appearance')).toHaveTextContent('Appearance: light')
|
||||
})
|
||||
|
||||
test('handles system theme with dark preference', async () => {
|
||||
setupMockEnvironment('system', true) // system theme, dark preference
|
||||
|
||||
render(
|
||||
<TestThemeProvider>
|
||||
<PageComponent />
|
||||
</TestThemeProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('theme-indicator')).toHaveTextContent('Current Theme: dark')
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('visual-appearance')).toHaveTextContent('Appearance: dark')
|
||||
})
|
||||
|
||||
test('handles system theme with light preference', async () => {
|
||||
setupMockEnvironment('system', false) // system theme, light preference
|
||||
|
||||
render(
|
||||
<TestThemeProvider>
|
||||
<PageComponent />
|
||||
</TestThemeProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('theme-indicator')).toHaveTextContent('Current Theme: light')
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('visual-appearance')).toHaveTextContent('Appearance: light')
|
||||
})
|
||||
|
||||
test('handles no stored theme (defaults to system)', async () => {
|
||||
setupMockEnvironment(null, false) // no stored theme, system prefers light
|
||||
|
||||
render(
|
||||
<TestThemeProvider>
|
||||
<PageComponent />
|
||||
</TestThemeProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('theme-indicator')).toHaveTextContent('Current Theme: light')
|
||||
})
|
||||
})
|
||||
|
||||
test('measures timing window of style changes', async () => {
|
||||
setupMockEnvironment('dark')
|
||||
|
||||
const timingData: Array<{ phase: string; timestamp: number; styles: any }> = []
|
||||
|
||||
const TimingPageComponent = () => {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const { theme } = useTheme()
|
||||
const isDark = mounted ? theme === 'dark' : false
|
||||
|
||||
// Record timing and styles for each render phase
|
||||
const currentStyles = {
|
||||
backgroundColor: isDark ? '#1f2937' : '#ffffff',
|
||||
color: isDark ? '#ffffff' : '#000000',
|
||||
}
|
||||
|
||||
timingData.push({
|
||||
phase: mounted ? 'CSR' : 'Initial',
|
||||
timestamp: performance.now(),
|
||||
styles: currentStyles,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="timing-page"
|
||||
style={currentStyles}
|
||||
>
|
||||
<div data-testid="timing-status">
|
||||
Phase: {mounted ? 'CSR' : 'Initial'} | Theme: {theme} | Visual: {isDark ? 'dark' : 'light'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(
|
||||
<TestThemeProvider>
|
||||
<TimingPageComponent />
|
||||
</TestThemeProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('timing-status')).toHaveTextContent('Phase: CSR')
|
||||
})
|
||||
|
||||
// Analyze timing and style changes
|
||||
console.log('\n=== Style Change Timeline ===')
|
||||
timingData.forEach((data, index) => {
|
||||
console.log(`${index + 1}. ${data.phase}: bg=${data.styles.backgroundColor}, color=${data.styles.color}`)
|
||||
})
|
||||
|
||||
// Check if there are style changes (this is visible flicker)
|
||||
const hasStyleChange = timingData.length > 1
|
||||
&& timingData[0].styles.backgroundColor !== timingData[timingData.length - 1].styles.backgroundColor
|
||||
|
||||
if (hasStyleChange)
|
||||
console.log('⚠️ Style changes detected - this causes visible flicker')
|
||||
else
|
||||
console.log('✅ No style changes detected')
|
||||
|
||||
expect(timingData.length).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CSS Application Timing Tests', () => {
|
||||
test('checks CSS class changes causing flicker', async () => {
|
||||
setupMockEnvironment('dark')
|
||||
|
||||
const cssStates: Array<{ className: string; timestamp: number }> = []
|
||||
|
||||
const CSSTestComponent = () => {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const { theme } = useTheme()
|
||||
const isDark = mounted ? theme === 'dark' : false
|
||||
|
||||
// Simulate Tailwind CSS class application
|
||||
const className = `min-h-screen ${isDark ? 'bg-gray-900 text-white' : 'bg-white text-black'}`
|
||||
|
||||
cssStates.push({
|
||||
className,
|
||||
timestamp: performance.now(),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="css-component"
|
||||
className={className}
|
||||
>
|
||||
<div data-testid="css-classes">Classes: {className}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(
|
||||
<TestThemeProvider>
|
||||
<CSSTestComponent />
|
||||
</TestThemeProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('css-classes')).toHaveTextContent('bg-gray-900 text-white')
|
||||
})
|
||||
|
||||
console.log('\n=== CSS Class Change Detection ===')
|
||||
cssStates.forEach((state, index) => {
|
||||
console.log(`${index + 1}. ${state.className}`)
|
||||
})
|
||||
|
||||
// Check if CSS classes have changed
|
||||
const hasCSSChange = cssStates.length > 1
|
||||
&& cssStates[0].className !== cssStates[cssStates.length - 1].className
|
||||
|
||||
if (hasCSSChange) {
|
||||
console.log('⚠️ CSS class changes detected - may cause style flicker')
|
||||
console.log(`From: "${cssStates[0].className}"`)
|
||||
console.log(`To: "${cssStates[cssStates.length - 1].className}"`)
|
||||
}
|
||||
|
||||
expect(hasCSSChange).toBe(true) // We expect to see this change
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases and Error Handling', () => {
|
||||
test('handles localStorage access errors gracefully', async () => {
|
||||
// Mock localStorage to throw an error
|
||||
const mockStorage = {
|
||||
getItem: jest.fn(() => {
|
||||
throw new Error('LocalStorage access denied')
|
||||
}),
|
||||
setItem: jest.fn(),
|
||||
removeItem: jest.fn(),
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: mockStorage,
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
|
||||
render(
|
||||
<TestThemeProvider>
|
||||
<PageComponent />
|
||||
</TestThemeProvider>,
|
||||
)
|
||||
|
||||
// Should fallback gracefully without crashing
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('theme-indicator')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Should default to light theme when localStorage fails
|
||||
expect(screen.getByTestId('visual-appearance')).toHaveTextContent('Appearance: light')
|
||||
})
|
||||
|
||||
test('handles invalid theme values in localStorage', async () => {
|
||||
setupMockEnvironment('invalid-theme-value')
|
||||
|
||||
render(
|
||||
<TestThemeProvider>
|
||||
<PageComponent />
|
||||
</TestThemeProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('theme-indicator')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Should handle invalid values gracefully
|
||||
const themeIndicator = screen.getByTestId('theme-indicator')
|
||||
expect(themeIndicator).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Performance and Regression Tests', () => {
|
||||
test('verifies ThemeProvider position fix reduces initialization delay', async () => {
|
||||
const performanceMarks: Array<{ event: string; timestamp: number }> = []
|
||||
|
||||
const PerformanceTestComponent = () => {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const { theme } = useTheme()
|
||||
|
||||
performanceMarks.push({ event: 'component-render', timestamp: performance.now() })
|
||||
|
||||
useEffect(() => {
|
||||
performanceMarks.push({ event: 'mount-start', timestamp: performance.now() })
|
||||
setMounted(true)
|
||||
performanceMarks.push({ event: 'mount-complete', timestamp: performance.now() })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (theme)
|
||||
performanceMarks.push({ event: 'theme-available', timestamp: performance.now() })
|
||||
}, [theme])
|
||||
|
||||
return (
|
||||
<div data-testid="performance-test">
|
||||
Mounted: {mounted.toString()} | Theme: {theme || 'loading'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
setupMockEnvironment('dark')
|
||||
|
||||
render(
|
||||
<TestThemeProvider>
|
||||
<PerformanceTestComponent />
|
||||
</TestThemeProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('performance-test')).toHaveTextContent('Theme: dark')
|
||||
})
|
||||
|
||||
// Analyze performance timeline
|
||||
console.log('\n=== Performance Timeline ===')
|
||||
performanceMarks.forEach((mark) => {
|
||||
console.log(`${mark.event}: ${mark.timestamp.toFixed(2)}ms`)
|
||||
})
|
||||
|
||||
expect(performanceMarks.length).toBeGreaterThan(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Solution Requirements Definition', () => {
|
||||
test('defines technical requirements to eliminate flicker', () => {
|
||||
const technicalRequirements = {
|
||||
ssrConsistency: 'SSR and CSR must render identical initial styles',
|
||||
synchronousDetection: 'Theme detection must complete synchronously before first render',
|
||||
noStyleChanges: 'No visible style changes should occur after hydration',
|
||||
performanceImpact: 'Solution should not significantly impact page load performance',
|
||||
browserCompatibility: 'Must work consistently across all major browsers',
|
||||
}
|
||||
|
||||
console.log('\n=== Technical Requirements ===')
|
||||
Object.entries(technicalRequirements).forEach(([key, requirement]) => {
|
||||
console.log(`${key}: ${requirement}`)
|
||||
expect(requirement).toBeDefined()
|
||||
})
|
||||
|
||||
// A successful solution should pass all these requirements
|
||||
})
|
||||
})
|
||||
})
|
@@ -62,24 +62,25 @@ const LocaleLayout = async ({
|
||||
className="color-scheme h-full select-auto"
|
||||
{...datasetMap}
|
||||
>
|
||||
<BrowserInitializer>
|
||||
<SentryInitializer>
|
||||
<TanstackQueryInitializer>
|
||||
<ThemeProvider
|
||||
attribute='data-theme'
|
||||
defaultTheme='system'
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute='data-theme'
|
||||
defaultTheme='system'
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
enableColorScheme={false}
|
||||
>
|
||||
<BrowserInitializer>
|
||||
<SentryInitializer>
|
||||
<TanstackQueryInitializer>
|
||||
<I18nServer>
|
||||
<GlobalPublicStoreProvider>
|
||||
{children}
|
||||
</GlobalPublicStoreProvider>
|
||||
</I18nServer>
|
||||
</ThemeProvider>
|
||||
</TanstackQueryInitializer>
|
||||
</SentryInitializer>
|
||||
</BrowserInitializer>
|
||||
</TanstackQueryInitializer>
|
||||
</SentryInitializer>
|
||||
</BrowserInitializer>
|
||||
</ThemeProvider>
|
||||
<RoutePrefixHandle />
|
||||
</body>
|
||||
</html>
|
||||
|
Reference in New Issue
Block a user