diff --git a/web/__tests__/real-browser-flicker.test.tsx b/web/__tests__/real-browser-flicker.test.tsx
index 52bdf4777f..f71e8de515 100644
--- a/web/__tests__/real-browser-flicker.test.tsx
+++ b/web/__tests__/real-browser-flicker.test.tsx
@@ -13,39 +13,60 @@ import { ThemeProvider } from 'next-themes'
import useTheme from '@/hooks/use-theme'
import { useEffect, useState } from 'react'
+const DARK_MODE_MEDIA_QUERY = /prefers-color-scheme:\s*dark/i
+
// 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(),
+ if (typeof window === 'undefined')
+ return
+
+ try {
+ window.localStorage.clear()
+ }
+ catch {
+ // ignore if localStorage has been replaced by a throwing stub
}
- // Mock system theme preference
- const mockMatchMedia = jest.fn((query: string) => ({
- matches: query.includes('dark') && systemPrefersDark,
- media: query,
- addListener: jest.fn(),
- removeListener: jest.fn(),
- }))
+ if (storedTheme === null)
+ window.localStorage.removeItem('theme')
+ else
+ window.localStorage.setItem('theme', storedTheme)
- if (typeof window !== 'undefined') {
- Object.defineProperty(window, 'localStorage', {
- value: mockStorage,
- configurable: true,
- })
+ document.documentElement.removeAttribute('data-theme')
- Object.defineProperty(window, 'matchMedia', {
- value: mockMatchMedia,
- configurable: true,
- })
+ const mockMatchMedia: typeof window.matchMedia = (query: string) => {
+ const listeners = new Set<(event: MediaQueryListEvent) => void>()
+ const isDarkQuery = DARK_MODE_MEDIA_QUERY.test(query)
+ const matches = isDarkQuery ? systemPrefersDark : false
+
+ const mediaQueryList: MediaQueryList = {
+ matches,
+ media: query,
+ onchange: null,
+ addListener: (listener: MediaQueryListListener) => {
+ listeners.add(listener)
+ },
+ removeListener: (listener: MediaQueryListListener) => {
+ listeners.delete(listener)
+ },
+ addEventListener: (_event, listener: EventListener) => {
+ if (typeof listener === 'function')
+ listeners.add(listener as MediaQueryListListener)
+ },
+ removeEventListener: (_event, listener: EventListener) => {
+ if (typeof listener === 'function')
+ listeners.delete(listener as MediaQueryListListener)
+ },
+ dispatchEvent: (event: Event) => {
+ listeners.forEach(listener => listener(event as MediaQueryListEvent))
+ return true
+ },
+ }
+
+ return mediaQueryList
}
- return { mockStorage, mockMatchMedia }
+ jest.spyOn(window, 'matchMedia').mockImplementation(mockMatchMedia)
}
// Simulate real page component based on Dify's actual theme usage
@@ -94,7 +115,17 @@ const TestThemeProvider = ({ children }: { children: React.ReactNode }) => (
describe('Real Browser Environment Dark Mode Flicker Test', () => {
beforeEach(() => {
+ jest.restoreAllMocks()
jest.clearAllMocks()
+ if (typeof window !== 'undefined') {
+ try {
+ window.localStorage.clear()
+ }
+ catch {
+ // ignore when localStorage is replaced with an error-throwing stub
+ }
+ document.documentElement.removeAttribute('data-theme')
+ }
})
describe('Page Refresh Scenario Simulation', () => {
@@ -323,35 +354,40 @@ describe('Real Browser Environment Dark Mode Flicker Test', () => {
describe('Edge Cases and Error Handling', () => {
test('handles localStorage access errors gracefully', async () => {
- // Mock localStorage to throw an error
+ setupMockEnvironment(null)
+
const mockStorage = {
getItem: jest.fn(() => {
throw new Error('LocalStorage access denied')
}),
setItem: jest.fn(),
removeItem: jest.fn(),
+ clear: jest.fn(),
}
- if (typeof window !== 'undefined') {
- Object.defineProperty(window, 'localStorage', {
- value: mockStorage,
- configurable: true,
- })
- }
-
- render(
-
-
- ,
- )
-
- // Should fallback gracefully without crashing
- await waitFor(() => {
- expect(screen.getByTestId('theme-indicator')).toBeInTheDocument()
+ Object.defineProperty(window, 'localStorage', {
+ value: mockStorage,
+ configurable: true,
})
- // Should default to light theme when localStorage fails
- expect(screen.getByTestId('visual-appearance')).toHaveTextContent('Appearance: light')
+ try {
+ render(
+
+
+ ,
+ )
+
+ // 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')
+ }
+ finally {
+ Reflect.deleteProperty(window, 'localStorage')
+ }
})
test('handles invalid theme values in localStorage', async () => {
@@ -403,6 +439,8 @@ describe('Real Browser Environment Dark Mode Flicker Test', () => {
setupMockEnvironment('dark')
+ expect(window.localStorage.getItem('theme')).toBe('dark')
+
render(
diff --git a/web/jest.config.ts b/web/jest.config.ts
index ebeb2f7d7e..6c2d88448c 100644
--- a/web/jest.config.ts
+++ b/web/jest.config.ts
@@ -160,7 +160,11 @@ const config: Config = {
testEnvironment: '@happy-dom/jest-environment',
// Options that will be passed to the testEnvironment
- // testEnvironmentOptions: {},
+ testEnvironmentOptions: {
+ // Match happy-dom's default to ensure Node.js environment resolution
+ // This prevents ESM packages like uuid from using browser exports
+ customExportConditions: ['node', 'node-addons'],
+ },
// Adds a location field to test results
// testLocationInResults: false,
@@ -189,10 +193,10 @@ const config: Config = {
// transform: undefined,
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
- // transformIgnorePatterns: [
- // "/node_modules/",
- // "\\.pnp\\.[^\\/]+$"
- // ],
+ // For pnpm: allow transforming uuid ESM package
+ transformIgnorePatterns: [
+ 'node_modules/(?!(.pnpm|uuid))',
+ ],
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
// unmockedModulePathPatterns: undefined,
diff --git a/web/package.json b/web/package.json
index a5cfd387f7..33ba0dc5a4 100644
--- a/web/package.json
+++ b/web/package.json
@@ -143,7 +143,7 @@
"@babel/core": "^7.28.3",
"@chromatic-com/storybook": "^3.1.0",
"@eslint-react/eslint-plugin": "^1.15.0",
- "@happy-dom/jest-environment": "^17.4.4",
+ "@happy-dom/jest-environment": "^20.0.0",
"@mdx-js/loader": "^3.1.0",
"@mdx-js/react": "^3.1.0",
"@next/bundle-analyzer": "15.5.4",
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
index 0a45f14ba0..2fcd0f17b1 100644
--- a/web/pnpm-lock.yaml
+++ b/web/pnpm-lock.yaml
@@ -345,8 +345,8 @@ importers:
specifier: ^1.15.0
version: 1.52.3(eslint@9.35.0(jiti@2.6.0))(ts-api-utils@2.1.0(typescript@5.8.3))(typescript@5.8.3)
'@happy-dom/jest-environment':
- specifier: ^17.4.4
- version: 17.6.3
+ specifier: ^20.0.0
+ version: 20.0.0(@jest/environment@29.7.0)(@jest/fake-timers@29.7.0)(@jest/types@29.6.3)(jest-mock@29.7.0)(jest-util@29.7.0)
'@mdx-js/loader':
specifier: ^3.1.0
version: 3.1.0(acorn@8.15.0)(webpack@5.100.2(esbuild@0.25.0)(uglify-js@3.19.3))
@@ -1644,9 +1644,15 @@ packages:
'@formatjs/intl-localematcher@0.5.10':
resolution: {integrity: sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q==}
- '@happy-dom/jest-environment@17.6.3':
- resolution: {integrity: sha512-HXuHKvpHLo9/GQ/yKMmKFyS1AYL2t9pL67+GfpYZfOAb29qD80EMozi50zRZk82KmNRBcA2A0/ErjpOwUxJrNg==}
+ '@happy-dom/jest-environment@20.0.0':
+ resolution: {integrity: sha512-dUyMDNJzPDFopSDyzKdbeYs8z9B4jLj9kXnru8TjYdGeLsQKf+6r0lq/9T2XVcu04QFxXMykt64A+KjsaJTaNA==}
engines: {node: '>=20.0.0'}
+ peerDependencies:
+ '@jest/environment': '>=25.0.0'
+ '@jest/fake-timers': '>=25.0.0'
+ '@jest/types': '>=25.0.0'
+ jest-mock: '>=25.0.0'
+ jest-util: '>=25.0.0'
'@headlessui/react@2.2.1':
resolution: {integrity: sha512-daiUqVLae8CKVjEVT19P/izW0aGK0GNhMSAeMlrDebKmoVZHcRRwbxzgtnEadUVDXyBsWo9/UH4KHeniO+0tMg==}
@@ -3416,6 +3422,9 @@ packages:
'@types/node@18.15.0':
resolution: {integrity: sha512-z6nr0TTEOBGkzLGmbypWOGnpSpSIBorEhC4L+4HeQ2iezKCi4f77kyslRwvHeNitymGQ+oFyIWGP96l/DPSV9w==}
+ '@types/node@20.19.20':
+ resolution: {integrity: sha512-2Q7WS25j4pS1cS8yw3d6buNCVJukOTeQ39bAnwR6sOJbaxvyCGebzTMypDFN82CxBLnl+lSWVdCCWbRY6y9yZQ==}
+
'@types/papaparse@5.3.16':
resolution: {integrity: sha512-T3VuKMC2H0lgsjI9buTB3uuKj3EMD2eap1MOuEQuBQ44EnDx/IkGhU6EwiTf9zG3za4SKlmwKAImdDKdNnCsXg==}
@@ -3475,6 +3484,9 @@ packages:
'@types/uuid@9.0.8':
resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==}
+ '@types/whatwg-mimetype@3.0.2':
+ resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
+
'@types/yargs-parser@21.0.3':
resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==}
@@ -5542,8 +5554,8 @@ packages:
hachure-fill@0.5.2:
resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==}
- happy-dom@17.6.3:
- resolution: {integrity: sha512-UVIHeVhxmxedbWPCfgS55Jg2rDfwf2BCKeylcPSqazLz5w3Kri7Q4xdBJubsr/+VUzFLh0VjIvh13RaDA2/Xug==}
+ happy-dom@20.0.0:
+ resolution: {integrity: sha512-GkWnwIFxVGCf2raNrxImLo397RdGhLapj5cT3R2PT7FwL62Ze1DROhzmYW7+J3p9105DYMVenEejEbnq5wA37w==}
engines: {node: '>=20.0.0'}
has-flag@4.0.0:
@@ -8246,6 +8258,9 @@ packages:
engines: {node: '>=0.8.0'}
hasBin: true
+ undici-types@6.21.0:
+ resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+
unicode-canonical-property-names-ecmascript@2.0.1:
resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==}
engines: {node: '>=4'}
@@ -8472,10 +8487,6 @@ packages:
webidl-conversions@4.0.2:
resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==}
- webidl-conversions@7.0.0:
- resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
- engines: {node: '>=12'}
-
webpack-bundle-analyzer@4.10.1:
resolution: {integrity: sha512-s3P7pgexgT/HTUSYgxJyn28A+99mmLq4HsJepMPzu0R8ImJc52QNqaFYW1Z2z2uIb1/J3eYgaAWVpaC+v/1aAQ==}
engines: {node: '>= 10.13.0'}
@@ -10105,12 +10116,12 @@ snapshots:
dependencies:
tslib: 2.8.1
- '@happy-dom/jest-environment@17.6.3':
+ '@happy-dom/jest-environment@20.0.0(@jest/environment@29.7.0)(@jest/fake-timers@29.7.0)(@jest/types@29.6.3)(jest-mock@29.7.0)(jest-util@29.7.0)':
dependencies:
'@jest/environment': 29.7.0
'@jest/fake-timers': 29.7.0
'@jest/types': 29.6.3
- happy-dom: 17.6.3
+ happy-dom: 20.0.0
jest-mock: 29.7.0
jest-util: 29.7.0
@@ -12201,6 +12212,10 @@ snapshots:
'@types/node@18.15.0': {}
+ '@types/node@20.19.20':
+ dependencies:
+ undici-types: 6.21.0
+
'@types/papaparse@5.3.16':
dependencies:
'@types/node': 18.15.0
@@ -12255,6 +12270,8 @@ snapshots:
'@types/uuid@9.0.8': {}
+ '@types/whatwg-mimetype@3.0.2': {}
+
'@types/yargs-parser@21.0.3': {}
'@types/yargs@17.0.33':
@@ -14709,9 +14726,10 @@ snapshots:
hachure-fill@0.5.2: {}
- happy-dom@17.6.3:
+ happy-dom@20.0.0:
dependencies:
- webidl-conversions: 7.0.0
+ '@types/node': 20.19.20
+ '@types/whatwg-mimetype': 3.0.2
whatwg-mimetype: 3.0.0
has-flag@4.0.0: {}
@@ -18125,6 +18143,8 @@ snapshots:
uglify-js@3.19.3: {}
+ undici-types@6.21.0: {}
+
unicode-canonical-property-names-ecmascript@2.0.1: {}
unicode-match-property-ecmascript@2.0.0:
@@ -18351,8 +18371,6 @@ snapshots:
webidl-conversions@4.0.2: {}
- webidl-conversions@7.0.0: {}
-
webpack-bundle-analyzer@4.10.1:
dependencies:
'@discoveryjs/json-ext': 0.5.7