You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

real-browser-flicker.test.tsx 13KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
  1. /**
  2. * Real Browser Environment Dark Mode Flicker Test
  3. *
  4. * This test attempts to simulate real browser refresh scenarios including:
  5. * 1. SSR HTML generation phase
  6. * 2. Client-side JavaScript loading
  7. * 3. Theme system initialization
  8. * 4. CSS styles application timing
  9. */
  10. import { render, screen, waitFor } from '@testing-library/react'
  11. import { ThemeProvider } from 'next-themes'
  12. import useTheme from '@/hooks/use-theme'
  13. import { useEffect, useState } from 'react'
  14. // Setup browser environment for testing
  15. const setupMockEnvironment = (storedTheme: string | null, systemPrefersDark = false) => {
  16. // Mock localStorage
  17. const mockStorage = {
  18. getItem: jest.fn((key: string) => {
  19. if (key === 'theme') return storedTheme
  20. return null
  21. }),
  22. setItem: jest.fn(),
  23. removeItem: jest.fn(),
  24. }
  25. // Mock system theme preference
  26. const mockMatchMedia = jest.fn((query: string) => ({
  27. matches: query.includes('dark') && systemPrefersDark,
  28. media: query,
  29. addListener: jest.fn(),
  30. removeListener: jest.fn(),
  31. }))
  32. if (typeof window !== 'undefined') {
  33. Object.defineProperty(window, 'localStorage', {
  34. value: mockStorage,
  35. configurable: true,
  36. })
  37. Object.defineProperty(window, 'matchMedia', {
  38. value: mockMatchMedia,
  39. configurable: true,
  40. })
  41. }
  42. return { mockStorage, mockMatchMedia }
  43. }
  44. // Simulate real page component based on Dify's actual theme usage
  45. const PageComponent = () => {
  46. const [mounted, setMounted] = useState(false)
  47. const { theme } = useTheme()
  48. useEffect(() => {
  49. setMounted(true)
  50. }, [])
  51. // Simulate common theme usage pattern in Dify
  52. const isDark = mounted ? theme === 'dark' : false
  53. return (
  54. <div data-theme={isDark ? 'dark' : 'light'}>
  55. <div
  56. data-testid="page-content"
  57. style={{ backgroundColor: isDark ? '#1f2937' : '#ffffff' }}
  58. >
  59. <h1 style={{ color: isDark ? '#ffffff' : '#000000' }}>
  60. Dify Application
  61. </h1>
  62. <div data-testid="theme-indicator">
  63. Current Theme: {mounted ? theme : 'unknown'}
  64. </div>
  65. <div data-testid="visual-appearance">
  66. Appearance: {isDark ? 'dark' : 'light'}
  67. </div>
  68. </div>
  69. </div>
  70. )
  71. }
  72. const TestThemeProvider = ({ children }: { children: React.ReactNode }) => (
  73. <ThemeProvider
  74. attribute="data-theme"
  75. defaultTheme="system"
  76. enableSystem
  77. disableTransitionOnChange
  78. enableColorScheme={false}
  79. >
  80. {children}
  81. </ThemeProvider>
  82. )
  83. describe('Real Browser Environment Dark Mode Flicker Test', () => {
  84. beforeEach(() => {
  85. jest.clearAllMocks()
  86. })
  87. describe('Page Refresh Scenario Simulation', () => {
  88. test('simulates complete page loading process with dark theme', async () => {
  89. // Setup: User previously selected dark mode
  90. setupMockEnvironment('dark')
  91. render(
  92. <TestThemeProvider>
  93. <PageComponent />
  94. </TestThemeProvider>,
  95. )
  96. // Check initial client-side rendering state
  97. const initialState = {
  98. theme: screen.getByTestId('theme-indicator').textContent,
  99. appearance: screen.getByTestId('visual-appearance').textContent,
  100. }
  101. console.log('Initial client state:', initialState)
  102. // Wait for theme system to fully initialize
  103. await waitFor(() => {
  104. expect(screen.getByTestId('theme-indicator')).toHaveTextContent('Current Theme: dark')
  105. })
  106. const finalState = {
  107. theme: screen.getByTestId('theme-indicator').textContent,
  108. appearance: screen.getByTestId('visual-appearance').textContent,
  109. }
  110. console.log('Final state:', finalState)
  111. // Document the state change - this is the source of flicker
  112. console.log('State change detection: Initial -> Final')
  113. })
  114. test('handles light theme correctly', async () => {
  115. setupMockEnvironment('light')
  116. render(
  117. <TestThemeProvider>
  118. <PageComponent />
  119. </TestThemeProvider>,
  120. )
  121. await waitFor(() => {
  122. expect(screen.getByTestId('theme-indicator')).toHaveTextContent('Current Theme: light')
  123. })
  124. expect(screen.getByTestId('visual-appearance')).toHaveTextContent('Appearance: light')
  125. })
  126. test('handles system theme with dark preference', async () => {
  127. setupMockEnvironment('system', true) // system theme, dark preference
  128. render(
  129. <TestThemeProvider>
  130. <PageComponent />
  131. </TestThemeProvider>,
  132. )
  133. await waitFor(() => {
  134. expect(screen.getByTestId('theme-indicator')).toHaveTextContent('Current Theme: dark')
  135. })
  136. expect(screen.getByTestId('visual-appearance')).toHaveTextContent('Appearance: dark')
  137. })
  138. test('handles system theme with light preference', async () => {
  139. setupMockEnvironment('system', false) // system theme, light preference
  140. render(
  141. <TestThemeProvider>
  142. <PageComponent />
  143. </TestThemeProvider>,
  144. )
  145. await waitFor(() => {
  146. expect(screen.getByTestId('theme-indicator')).toHaveTextContent('Current Theme: light')
  147. })
  148. expect(screen.getByTestId('visual-appearance')).toHaveTextContent('Appearance: light')
  149. })
  150. test('handles no stored theme (defaults to system)', async () => {
  151. setupMockEnvironment(null, false) // no stored theme, system prefers light
  152. render(
  153. <TestThemeProvider>
  154. <PageComponent />
  155. </TestThemeProvider>,
  156. )
  157. await waitFor(() => {
  158. expect(screen.getByTestId('theme-indicator')).toHaveTextContent('Current Theme: light')
  159. })
  160. })
  161. test('measures timing window of style changes', async () => {
  162. setupMockEnvironment('dark')
  163. const timingData: Array<{ phase: string; timestamp: number; styles: any }> = []
  164. const TimingPageComponent = () => {
  165. const [mounted, setMounted] = useState(false)
  166. const { theme } = useTheme()
  167. const isDark = mounted ? theme === 'dark' : false
  168. // Record timing and styles for each render phase
  169. const currentStyles = {
  170. backgroundColor: isDark ? '#1f2937' : '#ffffff',
  171. color: isDark ? '#ffffff' : '#000000',
  172. }
  173. timingData.push({
  174. phase: mounted ? 'CSR' : 'Initial',
  175. timestamp: performance.now(),
  176. styles: currentStyles,
  177. })
  178. useEffect(() => {
  179. setMounted(true)
  180. }, [])
  181. return (
  182. <div
  183. data-testid="timing-page"
  184. style={currentStyles}
  185. >
  186. <div data-testid="timing-status">
  187. Phase: {mounted ? 'CSR' : 'Initial'} | Theme: {theme} | Visual: {isDark ? 'dark' : 'light'}
  188. </div>
  189. </div>
  190. )
  191. }
  192. render(
  193. <TestThemeProvider>
  194. <TimingPageComponent />
  195. </TestThemeProvider>,
  196. )
  197. await waitFor(() => {
  198. expect(screen.getByTestId('timing-status')).toHaveTextContent('Phase: CSR')
  199. })
  200. // Analyze timing and style changes
  201. console.log('\n=== Style Change Timeline ===')
  202. timingData.forEach((data, index) => {
  203. console.log(`${index + 1}. ${data.phase}: bg=${data.styles.backgroundColor}, color=${data.styles.color}`)
  204. })
  205. // Check if there are style changes (this is visible flicker)
  206. const hasStyleChange = timingData.length > 1
  207. && timingData[0].styles.backgroundColor !== timingData[timingData.length - 1].styles.backgroundColor
  208. if (hasStyleChange)
  209. console.log('⚠️ Style changes detected - this causes visible flicker')
  210. else
  211. console.log('✅ No style changes detected')
  212. expect(timingData.length).toBeGreaterThan(1)
  213. })
  214. })
  215. describe('CSS Application Timing Tests', () => {
  216. test('checks CSS class changes causing flicker', async () => {
  217. setupMockEnvironment('dark')
  218. const cssStates: Array<{ className: string; timestamp: number }> = []
  219. const CSSTestComponent = () => {
  220. const [mounted, setMounted] = useState(false)
  221. const { theme } = useTheme()
  222. const isDark = mounted ? theme === 'dark' : false
  223. // Simulate Tailwind CSS class application
  224. const className = `min-h-screen ${isDark ? 'bg-gray-900 text-white' : 'bg-white text-black'}`
  225. cssStates.push({
  226. className,
  227. timestamp: performance.now(),
  228. })
  229. useEffect(() => {
  230. setMounted(true)
  231. }, [])
  232. return (
  233. <div
  234. data-testid="css-component"
  235. className={className}
  236. >
  237. <div data-testid="css-classes">Classes: {className}</div>
  238. </div>
  239. )
  240. }
  241. render(
  242. <TestThemeProvider>
  243. <CSSTestComponent />
  244. </TestThemeProvider>,
  245. )
  246. await waitFor(() => {
  247. expect(screen.getByTestId('css-classes')).toHaveTextContent('bg-gray-900 text-white')
  248. })
  249. console.log('\n=== CSS Class Change Detection ===')
  250. cssStates.forEach((state, index) => {
  251. console.log(`${index + 1}. ${state.className}`)
  252. })
  253. // Check if CSS classes have changed
  254. const hasCSSChange = cssStates.length > 1
  255. && cssStates[0].className !== cssStates[cssStates.length - 1].className
  256. if (hasCSSChange) {
  257. console.log('⚠️ CSS class changes detected - may cause style flicker')
  258. console.log(`From: "${cssStates[0].className}"`)
  259. console.log(`To: "${cssStates[cssStates.length - 1].className}"`)
  260. }
  261. expect(hasCSSChange).toBe(true) // We expect to see this change
  262. })
  263. })
  264. describe('Edge Cases and Error Handling', () => {
  265. test('handles localStorage access errors gracefully', async () => {
  266. // Mock localStorage to throw an error
  267. const mockStorage = {
  268. getItem: jest.fn(() => {
  269. throw new Error('LocalStorage access denied')
  270. }),
  271. setItem: jest.fn(),
  272. removeItem: jest.fn(),
  273. }
  274. if (typeof window !== 'undefined') {
  275. Object.defineProperty(window, 'localStorage', {
  276. value: mockStorage,
  277. configurable: true,
  278. })
  279. }
  280. render(
  281. <TestThemeProvider>
  282. <PageComponent />
  283. </TestThemeProvider>,
  284. )
  285. // Should fallback gracefully without crashing
  286. await waitFor(() => {
  287. expect(screen.getByTestId('theme-indicator')).toBeInTheDocument()
  288. })
  289. // Should default to light theme when localStorage fails
  290. expect(screen.getByTestId('visual-appearance')).toHaveTextContent('Appearance: light')
  291. })
  292. test('handles invalid theme values in localStorage', async () => {
  293. setupMockEnvironment('invalid-theme-value')
  294. render(
  295. <TestThemeProvider>
  296. <PageComponent />
  297. </TestThemeProvider>,
  298. )
  299. await waitFor(() => {
  300. expect(screen.getByTestId('theme-indicator')).toBeInTheDocument()
  301. })
  302. // Should handle invalid values gracefully
  303. const themeIndicator = screen.getByTestId('theme-indicator')
  304. expect(themeIndicator).toBeInTheDocument()
  305. })
  306. })
  307. describe('Performance and Regression Tests', () => {
  308. test('verifies ThemeProvider position fix reduces initialization delay', async () => {
  309. const performanceMarks: Array<{ event: string; timestamp: number }> = []
  310. const PerformanceTestComponent = () => {
  311. const [mounted, setMounted] = useState(false)
  312. const { theme } = useTheme()
  313. performanceMarks.push({ event: 'component-render', timestamp: performance.now() })
  314. useEffect(() => {
  315. performanceMarks.push({ event: 'mount-start', timestamp: performance.now() })
  316. setMounted(true)
  317. performanceMarks.push({ event: 'mount-complete', timestamp: performance.now() })
  318. }, [])
  319. useEffect(() => {
  320. if (theme)
  321. performanceMarks.push({ event: 'theme-available', timestamp: performance.now() })
  322. }, [theme])
  323. return (
  324. <div data-testid="performance-test">
  325. Mounted: {mounted.toString()} | Theme: {theme || 'loading'}
  326. </div>
  327. )
  328. }
  329. setupMockEnvironment('dark')
  330. render(
  331. <TestThemeProvider>
  332. <PerformanceTestComponent />
  333. </TestThemeProvider>,
  334. )
  335. await waitFor(() => {
  336. expect(screen.getByTestId('performance-test')).toHaveTextContent('Theme: dark')
  337. })
  338. // Analyze performance timeline
  339. console.log('\n=== Performance Timeline ===')
  340. performanceMarks.forEach((mark) => {
  341. console.log(`${mark.event}: ${mark.timestamp.toFixed(2)}ms`)
  342. })
  343. expect(performanceMarks.length).toBeGreaterThan(3)
  344. })
  345. })
  346. describe('Solution Requirements Definition', () => {
  347. test('defines technical requirements to eliminate flicker', () => {
  348. const technicalRequirements = {
  349. ssrConsistency: 'SSR and CSR must render identical initial styles',
  350. synchronousDetection: 'Theme detection must complete synchronously before first render',
  351. noStyleChanges: 'No visible style changes should occur after hydration',
  352. performanceImpact: 'Solution should not significantly impact page load performance',
  353. browserCompatibility: 'Must work consistently across all major browsers',
  354. }
  355. console.log('\n=== Technical Requirements ===')
  356. Object.entries(technicalRequirements).forEach(([key, requirement]) => {
  357. console.log(`${key}: ${requirement}`)
  358. expect(requirement).toBeDefined()
  359. })
  360. // A successful solution should pass all these requirements
  361. })
  362. })
  363. })