diff --git a/.unipi/docs/fix/2026-06-03-filter-chips-scroll-survive-remount-fix.md b/.unipi/docs/fix/2026-06-03-filter-chips-scroll-survive-remount-fix.md
new file mode 100644
index 0000000..93bff60
--- /dev/null
+++ b/.unipi/docs/fix/2026-06-03-filter-chips-scroll-survive-remount-fix.md
@@ -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
+
+
+ {outlet}
+
+
+```
+
+(`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.
diff --git a/src/components/messageStream/FilterChips.tsx b/src/components/messageStream/FilterChips.tsx
index a82519a..e92293f 100644
--- a/src/components/messageStream/FilterChips.tsx
+++ b/src/components/messageStream/FilterChips.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef } from "react";
+import { useEffect, useLayoutEffect, useRef } from "react";
import { useI18n } from "../../i18n";
import { typeFilterLabel } from "../../resourceTypeLabels";
@@ -19,10 +19,29 @@ export type FilterChipsProps = {
onTypeChange: (next: string) => void;
};
+// Persist the horizontal scroll position OUTSIDE the component lifecycle.
+// `PublicLayout` wraps every page in an `AnimatePresence` keyed by
+// `pathname + search`, so changing the `?type=…` query unmounts the current
+// `FilterChips` and mounts a fresh one. A `useRef` would reset on every
+// filter switch. A module-level value survives the re-mount and lets the
+// new instance restore the user's last scroll position synchronously on
+// first paint.
+let lastScrollLeft = 0;
+
export function FilterChips({ type, onTypeChange }: FilterChipsProps) {
const { t } = useI18n();
const scrollRef = useRef(null);
+ // Restore the saved scroll position before paint so the bar never flashes
+ // at scrollLeft=0 after a filter-driven re-mount.
+ useLayoutEffect(() => {
+ const el = scrollRef.current;
+ if (!el) return;
+ if (lastScrollLeft > 0 && el.scrollLeft !== lastScrollLeft) {
+ el.scrollLeft = lastScrollLeft;
+ }
+ }, []);
+
// Let a mouse wheel scroll the row horizontally when it overflows — desktop
// mice have no horizontal wheel and the scrollbar is hidden, so otherwise the
// last filters are unreachable. Touch/trackpad scroll natively.
@@ -38,13 +57,80 @@ export function FilterChips({ type, onTypeChange }: FilterChipsProps) {
return () => el.removeEventListener("wheel", onWheel);
}, []);
+ // Save the position only on user-initiated input (touchend, pointerup,
+ // wheel). The sibling `ScrollToTop` calls `window.scrollTo` after the
+ // re-mount, which on iOS Safari can collapse this sticky row's
+ // scrollLeft to 0 — that fires a `scroll` event too, but it's not a
+ // user gesture and we must not let it overwrite the saved value.
+ useEffect(() => {
+ const el = scrollRef.current;
+ if (!el) return;
+ const save = () => {
+ lastScrollLeft = el.scrollLeft;
+ };
+ const saveDeferred = () => {
+ // Wait one frame so the momentum scroll has had a chance to update.
+ window.requestAnimationFrame(save);
+ };
+ el.addEventListener("touchend", saveDeferred, { passive: true });
+ el.addEventListener("pointerup", saveDeferred, { passive: true });
+ el.addEventListener("wheel", saveDeferred, { passive: true });
+ return () => {
+ el.removeEventListener("touchend", saveDeferred);
+ el.removeEventListener("pointerup", saveDeferred);
+ el.removeEventListener("wheel", saveDeferred);
+ };
+ }, []);
+
+ // After mount, watch for the iOS Safari quirk that asynchronously resets
+ // scrollLeft to 0 (triggered by the page-level scroll-to-top that runs
+ // after a filter-driven re-mount). Re-apply the saved value for ~1.5s,
+ // skipping while the user is actively touching so a fresh scroll gesture
+ // isn't yanked back.
+ useEffect(() => {
+ const el = scrollRef.current;
+ if (!el) return;
+ if (lastScrollLeft <= 0) return;
+ const target = lastScrollLeft;
+ let cancelled = false;
+ let touching = false;
+ const onTouchStart = () => {
+ touching = true;
+ };
+ const onTouchEnd = () => {
+ touching = false;
+ };
+ const deadline = performance.now() + 1500;
+ const apply = () => {
+ if (cancelled || !el || touching) return;
+ if (el.scrollLeft === 0 && target > 0) el.scrollLeft = target;
+ };
+ const tick = () => {
+ if (cancelled) return;
+ apply();
+ if (performance.now() < deadline) window.requestAnimationFrame(tick);
+ };
+ window.requestAnimationFrame(tick);
+ el.addEventListener("scroll", apply, { passive: true });
+ el.addEventListener("touchstart", onTouchStart, { passive: true });
+ el.addEventListener("touchend", onTouchEnd, { passive: true });
+ el.addEventListener("touchcancel", onTouchEnd, { passive: true });
+ return () => {
+ cancelled = true;
+ el.removeEventListener("scroll", apply);
+ el.removeEventListener("touchstart", onTouchStart);
+ el.removeEventListener("touchend", onTouchEnd);
+ el.removeEventListener("touchcancel", onTouchEnd);
+ };
+ }, []);
+
const tabClass = (active: boolean) =>
[
"relative flex h-[52px] shrink-0 items-center whitespace-nowrap px-3 pb-4 pt-3 text-[15px] leading-6 outline-none transition-colors md:h-auto md:px-1 md:py-3 md:leading-none",
"border-b-0 md:border-b-2",
active
? "border-ark-gold font-medium text-ark-gold"
- : "border-transparent text-[#97989A] hover:text-ark-gold/80 md:text-neutral-400",
+ : "border-transparent text-[#97989A] [@media(hover:hover)]:hover:text-ark-gold/80 md:text-neutral-400",
].join(" ");
return (