From 7b86913611859802f5ff9e09669fb921b3c23d53 Mon Sep 17 00:00:00 2001 From: lens0021 Date: Sat, 20 Jun 2026 17:23:49 +0900 Subject: [PATCH] feat: center the seam on "N new" flush, matching cold-start entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping the "N new" floating button now scrolls so the divider between the freshly-flushed posts (above) and the already-seen ones (below) sits vertically centered, the same landing as a cold-start boundary divider — instead of jumping to the very top. Unifies the flushNonce and boundaryNonce scroll effects onto centerScrollOnDivider. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01VxfMbEXhsWRjjXEr5GMcF9 --- CLAUDE.md | 2 +- src/hooks/useSubscribedFeed.ts | 6 +++--- src/pages/SubscribedPage.tsx | 30 +++++++++++++----------------- 3 files changed, 17 insertions(+), 21 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0154f60..c581974 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,7 @@ 1. **폴링 (polling)** — 당겨서 새로고침 후 각 계정의 새 게시물을 API로 가져오는 작업. 진행 중에는 `N/total` pill 표시. 폴링은 조용히 진행됨 — 계정 상태 dot(초기 로딩 표시)을 띄우지 않음. HTTP 429를 받으면 이번 회차를 즉시 중단하고 `Retry-After`(없으면 `X-RateLimit-Reset`, 그래도 없으면 5분, 최대 1시간)만큼 다음 폴링을 건너뜀(웹·포그라운드 폴링도 Android 워커와 동일 정책). 폴링 중 일시적 실패(429·네트워크)는 dot으로 노출하지 않음. 2. **버퍼 (buffer)** — 폴링 완료 후 피드에 아직 반영되지 않은 게시물 임시 보관소. "N new" 버튼으로 표시됨 (대기 중 상태). -3. **피드 반영 (flush)** — "N new" 클릭 시 버퍼를 피드에 표시하는 동작. 새 글은 최상단에 삽입되고 화면은 **최상단(가장 새 글)으로 스크롤**(사용자가 새 글을 바로 읽도록). 구분선은 새 글과 기존 글 사이 경계로 남음. (콜드 스타트에서 마운트가 세팅한 경계 구분선은 `flushNonce`가 아닌 `boundaryNonce`를 올려 최상단이 아니라 구분선을 화면 중앙에 맞춰 스크롤함.) +3. **피드 반영 (flush)** — "N new" 클릭 시 버퍼를 피드에 표시하는 동작. 새 글은 최상단에 삽입되고, 새 글(위)과 기존 글(아래) 사이에 구분선이 남으며 화면은 **그 구분선이 세로 중앙에 오도록 스크롤**(콜드 스타트 경계 구분선과 동일한 착지). `flushNonce`(flush)와 `boundaryNonce`(콜드 스타트)는 서로 다른 신호지만 둘 다 SubscribedPage에서 `centerScrollOnDivider`로 같은 중앙 정렬 스크롤을 유발함. ## 엔드유저 UI diff --git a/src/hooks/useSubscribedFeed.ts b/src/hooks/useSubscribedFeed.ts index c3c4427..fea9196 100644 --- a/src/hooks/useSubscribedFeed.ts +++ b/src/hooks/useSubscribedFeed.ts @@ -424,9 +424,9 @@ export function useSubscribedFeed( setStagedCount(0); flush(); if (prevTopId) setDividerPostId(prevTopId); - // Tell SubscribedPage to scroll to the newest post — the user asked to see - // the new posts. (The mount-seeded boundary divider leaves this untouched, - // so a cold-start open never auto-scrolls.) + // Tell SubscribedPage to center the seam — the divider between the new posts + // (above) and the already-seen ones (below) — same landing as a cold-start + // boundary (see boundaryNonce). setFlushNonce((n) => n + 1); }, [flush]); diff --git a/src/pages/SubscribedPage.tsx b/src/pages/SubscribedPage.tsx index 27c5f80..427ed47 100644 --- a/src/pages/SubscribedPage.tsx +++ b/src/pages/SubscribedPage.tsx @@ -51,29 +51,25 @@ export function SubscribedPage({ // the first load after login, newly added accounts, and new-post polling. const { pullDistance, armed } = usePullToRefresh(scrollContainerRef, refresh); - // A user-initiated flush ("N new" tap) scrolls to the newest post so the new - // posts are what the user lands on; the divider stays as the seam below them. + // Both a user-initiated flush ("N new" tap, flushNonce) and the mount-seeded + // boundary divider (cold start after a background sync — the "tapped the + // notification after hours away" case, boundaryNonce) land the user on the + // seam, centered: the new/unseen posts above the fold, the already-seen ones + // below. The cold-start case also overrides scroll-anchor restore for that + // open (see useRestoreScrollAnchor's skip below) so the two don't fight. const lastFlushNonce = useRef(flushNonce); - useEffect(() => { - if (flushNonce === lastFlushNonce.current) return; - lastFlushNonce.current = flushNonce; - requestAnimationFrame(() => - scrollContainerRef.current?.scrollTo({ top: 0, behavior: "smooth" }), - ); - }, [flushNonce, scrollContainerRef]); - - // The mount-seeded boundary divider (cold start after a background sync — the - // "tapped the notification after hours away" case) lands the user at the seam, - // centered: the unseen posts above the fold, the already-seen ones below. This - // overrides scroll-anchor restore for that open (see useRestoreScrollAnchor's - // skip below) so the two don't fight over the position. const lastBoundaryNonce = useRef(boundaryNonce); useEffect(() => { - if (boundaryNonce === lastBoundaryNonce.current) return; + if ( + flushNonce === lastFlushNonce.current && + boundaryNonce === lastBoundaryNonce.current + ) + return; + lastFlushNonce.current = flushNonce; lastBoundaryNonce.current = boundaryNonce; const el = scrollContainerRef.current; if (el) requestAnimationFrame(() => centerScrollOnDivider(el)); - }, [boundaryNonce, scrollContainerRef]); + }, [flushNonce, boundaryNonce, scrollContainerRef]); // Track scroll position for the floating button and the status grid: // - the status grid and the top-of-feed signals show only at the very top