fix(ui): stabilize mobile hydration and layout overflow

This commit is contained in:
WuYongJie 2026-06-13 16:33:41 +08:00
parent b9476733fc
commit df05999c0f
4 changed files with 104 additions and 22 deletions

View File

@ -185,7 +185,7 @@ export function PlatformSplitLayout({
>
{leftContent}
</aside>
<div className="min-w-0 flex-1 overflow-y-auto rounded-xl p-3 max-lg:p-2"
<div className="min-w-0 flex-1 overflow-x-hidden overflow-y-auto rounded-xl p-3 max-lg:p-2"
style={{
backgroundColor: 'var(--color-surface-container-lowest)',
boxShadow: '0px 4px 20px rgba(45,51,53,0.06)',
@ -195,7 +195,7 @@ export function PlatformSplitLayout({
</div>
</>
) : (
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-y-auto rounded-xl p-3 max-lg:p-2"
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden overflow-y-auto rounded-xl p-3 max-lg:p-2"
style={{
backgroundColor: 'var(--color-surface-container-lowest)',
boxShadow: '0px 4px 20px rgba(45,51,53,0.06)',

View File

@ -1,6 +1,12 @@
import { render } from '@testing-library/react'
import { PlatformSplitLayout } from '../PlatformSplitLayout'
jest.mock('next/navigation', () => ({
useRouter: () => ({
push: jest.fn(),
}),
}))
describe('PlatformSplitLayout', () => {
it('默认使用统一的右栏宽度变量', () => {
const { container } = render(
@ -16,6 +22,20 @@ describe('PlatformSplitLayout', () => {
expect(rightPanel).toHaveClass('w-[var(--layout-right-panel-width)]')
expect(rightPanel).toHaveClass('max-w-[var(--layout-right-panel-max-width)]')
})
it('收住页面级横向溢出', () => {
const { container } = render(
<PlatformSplitLayout
leftContent={<div className="w-[200vw]">wide content</div>}
rightContent={null}
/>,
)
const contentPanel = container.querySelector('section > div')
expect(contentPanel).toHaveClass('overflow-x-hidden')
})
it('允许页面级覆盖右栏宽度', () => {
const { container } = render(
<PlatformSplitLayout

View File

@ -0,0 +1,60 @@
import { act, render, screen } from '@testing-library/react'
import { renderToString } from 'react-dom/server'
import { useMediaQuery } from './useMediaQuery'
function MediaQueryProbe({ query = '(max-width: 767px)' }: { query?: string }) {
const matches = useMediaQuery(query)
return <output data-testid="media-query">{String(matches)}</output>
}
function installMatchMedia(initialMatches: boolean) {
let matches = initialMatches
const listeners = new Set<(event: MediaQueryListEvent) => void>()
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
matches,
media: query,
onchange: null,
addEventListener: jest.fn((event: string, listener: (event: MediaQueryListEvent) => void) => {
if (event === 'change') listeners.add(listener)
}),
removeEventListener: jest.fn((event: string, listener: (event: MediaQueryListEvent) => void) => {
if (event === 'change') listeners.delete(listener)
}),
addListener: jest.fn(),
removeListener: jest.fn(),
dispatchEvent: jest.fn(),
}))
return {
setMatches(nextMatches: boolean) {
matches = nextMatches
act(() => {
listeners.forEach((listener) =>
listener({ matches: nextMatches, media: '(max-width: 767px)' } as MediaQueryListEvent),
)
})
},
}
}
describe('useMediaQuery', () => {
it('uses a stable server snapshot for hydration', () => {
installMatchMedia(true)
expect(renderToString(<MediaQueryProbe />)).toContain('false')
})
it('subscribes to client media query changes', () => {
const media = installMatchMedia(false)
render(<MediaQueryProbe />)
expect(screen.getByTestId('media-query')).toHaveTextContent('false')
media.setMatches(true)
expect(screen.getByTestId('media-query')).toHaveTextContent('true')
})
})

View File

@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useSyncExternalStore } from 'react'
/**
* 媒体查询 Hook
@ -13,30 +13,32 @@ import { useCallback, useEffect, useState } from 'react'
* const isDesktop = useMediaQuery('(min-width: 1024px)')
*/
export function useMediaQuery(query: string): boolean {
const getMatches = useCallback((): boolean => {
const subscribe = useCallback(
(onStoreChange: () => void) => {
if (typeof window === 'undefined') return () => {}
const mql = window.matchMedia(query)
mql.addEventListener('change', onStoreChange)
return () => {
mql.removeEventListener('change', onStoreChange)
}
},
[query],
)
const getSnapshot = useCallback(() => {
if (typeof window === 'undefined') return false
return window.matchMedia(query).matches
}, [query])
const [matches, setMatches] = useState<boolean>(getMatches)
const getServerSnapshot = useCallback(() => false, [])
useEffect(() => {
if (typeof window === 'undefined') return
const mql = window.matchMedia(query)
// 立即同步一次
setMatches(mql.matches)
function handleChange(event: MediaQueryListEvent) {
setMatches(event.matches)
}
mql.addEventListener('change', handleChange)
return () => mql.removeEventListener('change', handleChange)
}, [query])
return matches
return useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot,
)
}
/**