diff --git a/web/src/hooks/useScrollMemory.test.ts b/web/src/hooks/useScrollMemory.test.ts index 28a376e..79a7dfa 100644 --- a/web/src/hooks/useScrollMemory.test.ts +++ b/web/src/hooks/useScrollMemory.test.ts @@ -1,4 +1,4 @@ -import { shouldRememberScroll } from './useScrollMemory.ts' +import { shouldPersistScrollSample, shouldRememberScroll } from './useScrollMemory.ts' function check(name: string, condition: boolean) { if (!condition) throw new Error(`useScrollMemory: ${name}`) @@ -11,4 +11,41 @@ check('media detail remembers scroll', shouldRememberScroll('/media/media-1')) check('settings does not remember scroll', !shouldRememberScroll('/settings')) check('player does not remember scroll', !shouldRememberScroll('/play/media-1')) +check( + 'route transition collapse is ignored', + !shouldPersistScrollSample({ + current: 0, + lastSaved: 1800, + height: 400, + lastHeight: 4200, + }), +) +check( + 'intentional scroll to top is persisted', + shouldPersistScrollSample({ + current: 0, + lastSaved: 1800, + height: 4200, + lastHeight: 4200, + }), +) +check( + 'normal downward scroll is persisted', + shouldPersistScrollSample({ + current: 1900, + lastSaved: 1800, + height: 4200, + lastHeight: 4200, + }), +) +check( + 'content growth still allows persist', + shouldPersistScrollSample({ + current: 1800, + lastSaved: 1800, + height: 5200, + lastHeight: 4200, + }), +) + console.log('useScrollMemory.test.ts ok') diff --git a/web/src/hooks/useScrollMemory.ts b/web/src/hooks/useScrollMemory.ts index 0ff4848..925f2be 100644 --- a/web/src/hooks/useScrollMemory.ts +++ b/web/src/hooks/useScrollMemory.ts @@ -39,6 +39,23 @@ export function shouldRememberScroll(pathname: string): boolean { ) } +/** + * 路由切换时 Outlet 内容变矮,浏览器会把共享滚动容器的 scrollTop 钳低, + * 并可能同步触发 scroll 事件。这种“假滚动”不能写入存储,否则返回时永远回到顶部。 + */ +export function shouldPersistScrollSample(input: { + current: number + lastSaved: number + height: number + lastHeight: number +}): boolean { + const { current, lastSaved, height, lastHeight } = input + if (height + 1 < lastHeight && current < lastSaved) { + return false + } + return true +} + /** * 记住列表页的滚动位置。页面内容会异步长高,因此恢复期间会监听内容高度, * 直到目标位置可达;期间用户主动滚动会立即接管,避免和恢复逻辑抢滚动条。 @@ -61,6 +78,7 @@ export function useScrollMemory(pathname: string, userKey = 'anonymous'): void { let restoreFrame = 0 let restorePumpUntil = 0 let lastSaved = saved + let lastHeight = el.scrollHeight const stopRestore = () => { if (restoreFrame) { @@ -80,6 +98,7 @@ export function useScrollMemory(pathname: string, userKey = 'anonymous'): void { restoring = false stopRestore() lastSaved = Math.round(el.scrollTop) + lastHeight = el.scrollHeight writeScrollPosition(key, lastSaved) } @@ -108,6 +127,19 @@ export function useScrollMemory(pathname: string, userKey = 'anonymous'): void { const saveNow = () => { if (restoring) return const current = Math.round(el.scrollTop) + const height = el.scrollHeight + if ( + !shouldPersistScrollSample({ + current, + lastSaved, + height, + lastHeight, + }) + ) { + lastHeight = height + return + } + lastHeight = height if (current === lastSaved) return lastSaved = current writeScrollPosition(key, current) @@ -118,6 +150,7 @@ export function useScrollMemory(pathname: string, userKey = 'anonymous'): void { restoring = false stopRestore() lastSaved = Math.round(el.scrollTop) + lastHeight = el.scrollHeight writeScrollPosition(key, lastSaved) } @@ -172,6 +205,8 @@ export function useScrollMemory(pathname: string, userKey = 'anonymous'): void { window.removeEventListener('touchstart', cancelRestore, true) window.removeEventListener('keydown', onKeyDown, true) stopRestore() + // 清理时写入最后一次有效位置,避免依赖已被钳制的 el.scrollTop。 + writeScrollPosition(key, lastSaved) } }, [pathname, userKey]) }