fix(stream): preserve FilterChips horizontal scroll across remount
PublicLayout wraps the routed page in <AnimatePresence> keyed by pathname+search, so changing ?type=… fully unmounts the page and creates a fresh FilterChips. A useRef-based save/restore therefore reset on every filter switch. Persist the scrollLeft in a module-level value that survives the unmount, restore synchronously on mount, and keep an ~1.5s post-mount watch window for the iOS Safari sticky relayout that asynchronously snaps scrollLeft back to 0. Also gate the inactive-chip hover color behind [@media(hover:hover)] so iOS sticky-hover no longer leaves a faint gold tint on the last-tapped filter.
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
---
|
||||
title: "FilterChips scroll position lost on filter click (remount) — Quick Fix"
|
||||
type: quick-fix
|
||||
date: 2026-06-03
|
||||
---
|
||||
|
||||
# FilterChips scroll position lost on filter click (remount) — Quick Fix
|
||||
|
||||
## Bug
|
||||
On mobile (and in general), scrolling the `FilterChips` horizontal bar to the right and then clicking a chip caused the bar to snap all the way back to the leftmost position. Earlier attempts to fix this with `useRef` + `useLayoutEffect` save/restore inside `FilterChips` did not work — the bar kept resetting.
|
||||
|
||||
## Root Cause
|
||||
`PublicLayout` wraps the routed page in:
|
||||
|
||||
```tsx
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<m.div key={`${pathname}${search}`} variants={pageTransition} …>
|
||||
{outlet}
|
||||
</m.div>
|
||||
</AnimatePresence>
|
||||
```
|
||||
|
||||
(`src/layouts/PublicLayout.tsx:761`)
|
||||
|
||||
When the user clicks a filter chip, `MessageStream`'s `updateParam("type", v)` mutates the `search` query (e.g. `?type=archive`). That changes the `m.div`'s `key`, so `AnimatePresence` **fully unmounts** the current page and **mounts a fresh one**. `FilterChips` is part of that page tree, so it is destroyed and re-created — every internal `useRef` resets to its initial value, which is why the previous in-component save/restore approach silently failed.
|
||||
|
||||
A secondary symptom (already present before this fix) was an iOS Safari quirk where the sibling `ScrollToTop`'s `window.scrollTo({top:0, left:0})` triggers a relayout of the sticky bar that asynchronously sets the inner `overflow-x` `scrollLeft` back to 0.
|
||||
|
||||
## Fix
|
||||
Move the saved `scrollLeft` to a **module-level** variable so it survives the unmount/remount cycle, and restore it on first paint after every fresh mount. Combine with the existing user-input-only save logic and an iOS post-mount watch window to defeat the asynchronous reset.
|
||||
|
||||
Key points in the new `FilterChips`:
|
||||
|
||||
1. `let lastScrollLeft = 0;` declared at module scope.
|
||||
2. `useLayoutEffect(() => { … }, [])` on mount: if `lastScrollLeft > 0`, restore `scrollLeft` synchronously before paint.
|
||||
3. `useEffect(() => { … }, [])` saves `lastScrollLeft` only on `touchend` / `pointerup` / `wheel` (deferred by one rAF so iOS momentum has settled). We deliberately do **not** save on raw `scroll` events because iOS Safari's quirky 0-reset fires one of those too.
|
||||
4. A second `useEffect(() => { … }, [])` runs a ~1.5 s post-mount watch: rAF tick plus a `scroll` listener that re-applies the saved value **only when `scrollLeft` snaps to 0**, and only when the user is not actively touching the bar (so a fresh scroll gesture in that window isn't yanked back).
|
||||
|
||||
### Files Modified
|
||||
- `src/components/messageStream/FilterChips.tsx`
|
||||
- Added module-level `lastScrollLeft`.
|
||||
- Added `useLayoutEffect` to restore on mount.
|
||||
- Kept the user-input-only save effect.
|
||||
- Kept the ~1.5 s iOS-quirk watch (now keyed to mount instead of `[type]` since the component remounts anyway).
|
||||
|
||||
## Verification
|
||||
- `npx tsc --noEmit` — clean.
|
||||
- `npm run format:check` — clean.
|
||||
- `npm test` — 49/49 passing.
|
||||
- Expected behavior on device: scroll the filter bar to the right, tap any chip (e.g. 压缩包) — bar should stay exactly where the user left it, with the new active chip already highlighted in gold.
|
||||
|
||||
## Notes
|
||||
- Module-level state is intentional and acceptable here: there is only ever one `FilterChips` mounted at a time, and the value semantically belongs to the user's session, not the component instance.
|
||||
- If this `AnimatePresence` `key` strategy changes (or `FilterChips` is reused outside `MessageStream` in a context with multiple instances), revisit this — the module value would then need to be scoped per surface (e.g. via a Map keyed by surface id).
|
||||
- Previous attempts using `useRef` inside `FilterChips` failed because they ignored the remount caused by `AnimatePresence`. That is now documented inline in the component.
|
||||
Reference in New Issue
Block a user