Skip to content

Migrate paginated feeds to Paging 3 + @Immutable + fix Main-thread stutters#90

Merged
malkoG merged 15 commits intohackers-pub:mainfrom
dalinaum:feat/paging3-and-immutable
Apr 15, 2026
Merged

Migrate paginated feeds to Paging 3 + @Immutable + fix Main-thread stutters#90
malkoG merged 15 commits intohackers-pub:mainfrom
dalinaum:feat/paging3-and-immutable

Conversation

@dalinaum
Copy link
Copy Markdown
Contributor

@dalinaum dalinaum commented Apr 12, 2026

Summary

기존 수동 커서 페이지네이션(UiState.posts + loadMore() + endCursor)을 Jetpack Paging 3 로 마이그레이션. 병행해서:

  • Compose stability 확보 (@Immutable 어노테이션)
  • 피드 네트워크 payload 감축
  • 앱 시작 / 탭 전환 / 스크롤 중 Main-thread block 제거 (runBlocking, 동기 파일 I/O, Repository 응답 매핑, HTML/코드 파싱, 매 recomposition CPU 작업)
  • PagingConfig 튜닝 (cascading prefetch 방지)
  • ViewModel 테스트 61개 추가
  • 중복 포스트로 인한 LazyColumn 크래시 hotfix

이전 PR #89 (null-safety + UI tests) 와는 독립 브랜치. 최신 main 위에 rebase 완료#88(Profile tabs), #89(PostDetailStateDispatch), #91(empty-state refresh), #92(ArticleCard styling), #93(recommended actors), #97(article drafts) 등의 상위 변경과 공존하도록 각 화면/ViewModel 을 수동 각색.

커밋 구성

Paging 3 마이그레이션

# Commit 내용
A Add Paging 3 infrastructure, repository adapters, and @Immutable CursorPagingSource, PostOverlay, 8개 repository adapter, 신규 GraphQL 쿼리(PostReplies, ActorPosts), 모든 도메인 모델에 @Immutable, paging/test 의존성
B Migrate Notifications to Paging 3 Flow<PagingData<Notification>>, markAsSeenLaunchedEffect(loadState) 로 이동
C Migrate Timeline to Paging 3 (preserve drafts & recommended actors) PR #97 draftCount 배지 + PR #93 추천 버튼 보존, PostOverlayStore 기반 낙관 업데이트
D Migrate PostDetail replies to Paging 3 (preserve StateDispatch) 답글만 pager, PR #89 PostDetailStateDispatch 그대로 유지
E Migrate Explore to Paging 3 selectedTab.flatMapLatest 탭별 독립 pager
F Migrate Profile to Paging 3 (preserve tabs, attachments, fields) 3개 독립 pager + 공유 PostOverlayStore, PR #88 attachments/fields 유지, dead-field cleanup
G Polish: thumbnailUrl in media grid, ViewModel tests, overlay coverage 썸네일, 테스트 61개, composeCompiler 리포트 블록(주석)

Main-thread stutter 수정

# Commit 내용
H Remove runBlocking from Main-thread startup paths AppModule Auth Interceptor + MainActivity.onCreate critical path → SessionManager.sessionTokenState StateFlow 로 전환
I Offload SettingsViewModel file I/O to Dispatchers.IO 캐시 크기 계산 / 삭제를 IO 디스패처로
J Memoize Compose hot paths (DateTimeFormatter, ReactionPicker, relative time) remember { } / remember(key) 로 매 recomposition CPU 작업 제거
L Offload repository response mapping to Dispatchers.Default (MT-6) 실기에서 Notifications 진입 시 로딩 스피너가 얼어붙는 증상 해결. getNotifications / getPersonalTimeline / getPublicTimeline / getLocalTimeline / getPostDetail / getProfile / getActorArticles / getActorNotes 응답 매핑 블록만 withContext(Dispatchers.Default) 로 이관 (Apollo .execute() 는 이미 self-dispatching 이라 감싸지 않음)
M Tune PagingConfig: prefetchDistance 20→5, initialLoadSize 30→20 prefetchDistance = 20 이 첫 렌더 직후 page 2/3/4 를 chained 로 발동시키던 현상 제거. 서버 first:20initialLoadSize 일치시켜 desired-vs-delivered mismatch 해소
N Offload HTML and code-block parsing to Dispatchers.Default HtmlContent / CodeBlockView 가 매 새 아이템마다 Main 에서 파싱 → 스크롤 스터터. 프로세스 LRU 캐시 + 길이 임계값 기반 비동기 분기 도입

변경된 화면

화면 변경
Notifications Flow<PagingData<Notification>> 로 전환. markAsSeenLaunchedEffect(loadState) 로 이동
Timeline (개인 피드) Flow<PagingData<Post>> + PostOverlayStore. draft 배지(#97) / 추천 액터 버튼(#93) 그대로 유지
PostDetail 메인 post 는 UiState, 답글만 Flow<PagingData<Post>>. #89 PostDetailStateDispatch 유지 (content 람다 내에서 collectAsLazyPagingItems). 새 PostReplies 쿼리로 답글 페이지네이션 시 메인 post 중복 fetch 제거
Explore (Local/Global) selectedTab.flatMapLatest 로 탭별 독립 pager
Profile (Posts/Notes/Articles) 3개 독립 Flow<PagingData<Post>>. 공유 PostOverlayStore 로 탭 간 낙관적 업데이트 자동 전파. #88 ProfileAttachments / ProfileFields / ProfileTabBar composable 그대로 유지. 새 ActorPosts 쿼리
Search 페이지네이션 없음 — 변경 없음

핵심 인프라 추가

  • CursorPagingSource(after: String?) -> Result<CursorPage<T>> 람다 하나 받는 제네릭 PagingSource. 각 페이지네이션 엔드포인트는 어댑터 확장함수 한 줄로 연결.
  • cursorPager 팩토리 — 표준 PagingConfig 감싸는 헬퍼. 튜닝된 기본값:
    • pageSize = 20 (서버 first:20 고정과 일치)
    • prefetchDistance = 5 (뷰포트 절반 정도의 headroom — 스크롤 끝 근처에서만 다음 페이지 trigger)
    • initialLoadSize = 20 (서버의 실제 returned page size 와 일치 → desired-vs-delivered mismatch 없음)
  • PostOverlay + PostOverlayStore — 낙관적 share/reaction 업데이트를 PagingData 스트림에 map 으로 투영. 실패 시 overlay 만 clear 하면 서버 authoritative 상태로 자동 복귀. PagingSource invalidate 없이 즉각 UI 반영.
  • distinctByEffectiveIdsharedPost?.id ?: id 기준으로 페이지 간 중복 필터. LazyColumn "Key was already used" 크래시와 "원본+repost wrapping 중복 표시" 두 버그 동시 해결. Fix duplicate post crash in LazyColumn #83 의 Repository-레벨 outer-id 디듀프(distinctBy { it.id }) 는 방어선으로 유지하고, 이 헬퍼는 paging layer 에 추가로 덧댐.
  • HtmlContent / CodeBlockView LRU 캐시 — 프로세스 레벨 LruCache<Key, AnnotatedString> (256 / 128 entries). 키에 테마 색상 포함 → 다크모드 토글 시 정확히 재파싱.

Main-thread stutter 수정 상세 (L / M / N 커밋)

실기 Notifications 진입 시 "로딩 스피너가 빙글 돌다가 얼어붙는" 증상에서 출발. 감사 결과 세 가지가 누적되는 구조:

L — Repository 응답 매핑이 Main 에서 실행 (MT-6, CRITICAL)

  • Paging 3 PagingSource.load()cachedIn(viewModelScope) 체인을 타고 Main.immediate 에서 호출됨.
  • Apollo .execute() 자체는 proper suspend (OkHttp Dispatcher 자체 스레드 + DefaultApolloStoreDispatchers.Default) 로 Main 을 block 하지 않음. 하지만 .execute() 가 resume 된 이후 매핑 블록(mapNotNull { toPost(...) }, Instant.parse(...), nested actors/post 매핑 등) 은 caller dispatcher (= Main) 에서 수행됨.
  • toNotification() 이 아이템당 Instant.parse + 6개 sealed 서브타입 분기 + nested post 추출 → 초기 2030 아이템이면 누적 50200 ms Main 점유. 스피너 애니메이션이 그 기간 동안 frame callback 을 받지 못해 얼어붙음.
  • 수정: 매핑 블록만 withContext(Dispatchers.Default) 로 이관. .execute() 는 바깥에 유지 (blanket wrap 은 context switch 낭비). getPostReplies / getActorPosts 는 마이그레이션 시점에 이미 적용돼 있었고, 나머지 6개는 MT-6 deferred 로 남아있던 것을 이번에 일괄 적용.

M — PagingConfig 가 cascading prefetch 유발

  • 기존 prefetchDistance = 20 + pageSize = 20 → 첫 렌더 직후 distance-from-end < 20 이 바로 충족되어 page 2 가 즉시 trigger. page 2 도착 후 또 scroll → page 3 → page 4 chained.
  • 기존 initialLoadSize = 30 도 문제: 서버는 first:20 고정이라 30 요청해도 20 만 돌려줌. Paging 이 "window 가 under-filled" 로 판정하여 첫 로드 직후 append 를 즉시 schedule.
  • 수정: prefetchDistance = 5 (뷰포트 절반 headroom, 끝 근처 스크롤에만 발동), initialLoadSize = 20 (서버 실제 크기와 일치). 페이지네이션이 사용자 스크롤에만 반응하게 됨.

N — HtmlContent / CodeBlockView 가 Main 에서 파싱

  • 두 컴포넌트 모두 remember(html) { parseXxx(...) } 로 메모이즈 돼 있었지만, 첫 composition 시 (새로 보이는 LazyColumn 아이템마다) remember 람다가 Main 에서 실행됨.
  • 긴 아티클 (헤더 / 리스트 / 코드블록 / mention / hashtag 많은 글): 파싱 per item 10~50ms. 빠른 스크롤 중 새 아이템 진입 때마다 누적 stutter.
  • 수정: 프로세스 레벨 LruCache (HtmlContent 256 / CodeBlockView 128 entries, key = (html, themed colors)) + 길이 기반 분기:
    • 캐시 히트 or 짧은 html (< 500 / < 300) → 동기 파싱 (placeholder flash 없음)
    • 긴 html + 캐시 미스 → produceState + withContext(Dispatchers.Default) 로 비동기. 빈 AnnotatedString 을 첫 프레임에 띄우고 파싱 완료 후 다음 프레임에 Text 업데이트.
  • 캐시가 프로세스 레벨이라 Timeline 에서 본 글을 Profile / Explore 에서 재노출 시 재파싱 없음.

Apollo / Retrofit 에 대한 중요한 전제

apolloClient.query/mutation(...).execute()proper suspend. Retrofit 의 suspend fun 과 동일하게 Main 을 block 하지 않음. 따라서 Repository 전체를 withContext(Dispatchers.IO) 로 감싸는 blanket 접근은 하지 않음. 매핑 블록만 선택적 래핑 (Dispatchers.Default — CPU-bound 이므로 IO 가 아닌 Default).

GraphQL 쿼리 변경

클라이언트 쿼리만 변경, 서버 스키마 변경 없음.

신규

  • PostReplies($id, $after) — 답글만 반환 (PostDetail 의 풀 post + reaction + 답글 세트 대신)
  • ActorPosts($handle, $after) — Posts 탭 페이지네이션 (ActorNotes / ActorArticles 와 동일 패턴)

기존 쿼리 trim

  • ActorByHandle 에서 posts(first:20, after:$after)$after: String 파라미터 제거 — 헤더 전용 쿼리로 단순화. 페이지네이션은 ActorPosts 가 담당.
  • PostFields / SharedPostFieldsmentions(first: 20)그대로 유지.

Payload 절감 효과

  • Profile 열기: ~300 KB → 몇 KB (헤더만)
  • PostDetail 답글 페이지네이션: 매 페이지 post 본문 재전송 → 답글만
  • 피드 그리드 이미지 (MediaImage): thumbnailUrl 선호로 2000×2000 이미지를 150dp 에 표시하던 낭비 제거
  • 첫 렌더 후 즉시 발동되던 page 2/3/4 chained prefetch 제거 (M 커밋)

Compose Stability

  • domain/model/Models.kt 의 모든 data class@Immutable 추가 (Post, Actor, Media, ReactionGroup, Notification + 그 sealed 하위, ArticleDraft, AccountLink, ActorField 등).
  • app/build.gradle.ktscomposeCompiler { reportsDestination = ... } 블록 추가 (주석 처리됨 — 활성화하면 app/build/compose_compiler/ 에 stability 리포트 생성).

이미지/미디어

MediaImageQuotedPostPreview 의 그리드 이미지가 기존엔 풀 해상도 url 을 사용. thumbnailUrl 있으면 우선 사용하도록 변경 — 150dp 표시에 2000×2000 내려받던 낭비 제거. 풀뷰(MediaPreviewDialog) / 다운로드 (DownloadManager) / 외부 공유 intent 는 여전히 url 원본.

UI 버그 수정

LazyPagingItems 초기 상태 플리커

loadState.refreshNotLoading(endOfPaginationReached=false)Loading → 결과 순으로 emit 되는데, 초기 NotLoading + itemCount==0 이 첫 프레임에 매칭되어 "no posts" 엠티 UI 가 순간적으로 깜빡였음. when 브랜치 순서 재정렬로 해결. Timeline / Explore / Notifications / Profile 동일 패턴 적용.

테스트

테스트 의존성 추가

  • kotlinx-coroutines-testrunTest / StandardTestDispatcher
  • turbine — Flow 테스트 (follow-up 용)
  • mockk — Repository / SessionManager 등 모킹
  • androidx.paging:paging-testing — PagingData 스냅샷 (follow-up 용)

추가된 테스트

파일 주요 커버
PostOverlayTest applyOverlay / applyOverlays — 직접 + sharedPost 전파, partial override, 0 clamp
TimelineViewModelTest reaction picker, share/unshare/toggleReaction → repository 호출 검증, sharedPost target id 라우팅, 실패 경로. init { loadDraftCount() }getArticleDrafts() 스텁
ExploreViewModelTest 초기 LOCAL 탭, selectTab (탭은 이제 독립 StateFlow), reaction picker, toggleFavourite ❤️
PostDetailViewModelTest loadPost 성공/실패, canDelete 세션 핸들 매칭, 낙관적 share/unshare UiState mutate, delete 플로우, toggleReaction 그룹 생성/제거
ProfileViewModelTest init loadProfile, selectTab (독립 StateFlow), follow/unfollow/block → repository 라우팅 + actionError, refresh 재fetch. ProfileResult 의 posts 필드 제거된 새 시그니처 반영
NotificationsViewModelTest markAsSeenNotificationStateManager 위임
PostDetailContentTest (기존 #89) replies: List<Post>LazyPagingItems<Post> 시그니처 대응

총 110 테스트 통과 (기존 + 신규 합산).

공통 유틸

  • testutil/MainDispatcherRuleviewModelScopeDispatchers.Main 을 쓰므로 테스트 중 TestDispatcher 로 스왑하는 JUnit rule.

스코프 외 (별도 PR 후보)

  • PostDetail 의 shares/quotes 모달: 현재 한 번에 전부 로드. Paging 3 로 전환 가능하지만 스크롤 기반 UI 가 아니라 우선순위 낮음.
  • Coil ImageLoaderFactory 도입 (메모리/디스크 캐시 크기 튜닝). 썸네일 적용 효과 본 뒤 필요 시.
  • 기존 pre-existing lint error: SettingsViewModel.kt:91 packageInfo.longVersionCode 가 API 28 필요하나 minSdk=26. 별도 PR.

Test plan

  • ./gradlew :app:assembleDebug — 빌드 성공
  • ./gradlew :app:testDebugUnitTest — 110/110 통과
  • 실기기 회귀 확인:
    • Notifications 탭 진입: 로딩 스피너가 끊김 없이 회전하다 리스트로 자연스럽게 전환 (L 커밋 효과)
    • Timeline / Explore 스크롤: 새 아이템이 들어올 때 스터터 없음 (N 커밋 효과)
    • 긴 아티클 포스트 처음 볼 때 placeholder flash 가 수용 가능한지 (threshold 500 튜닝 필요 여부)
    • 첫 페이지 로드 후 page 2 가 즉시 따라붙지 않고, 사용자가 실제로 끝 근처까지 스크롤해야 trigger (M 커밋 효과 — Network Inspector 또는 Logcat 으로 확인)
    • Profile 3 탭 독립 페이지네이션 + 공유 overlay 전파 (한 탭에서 share → 다른 탭에도 반영)
    • Profile attachments 그리드 + fields 렌더링 그대로 (Add profile tabs and attachments display #88)
    • PostDetail: PostDetailStateDispatch → 답글 pagination, 메인 post share/reaction 낙관적 업데이트
    • Notifications: markAsSeen 동작 (배지 사라짐), 딥링크(/notifications) 진입
    • Timeline top bar: article 버튼 draft 배지(Add article creation and draft management #97) + 추천 버튼(Add recommended actors screen #93) + settings + 아바타 순서
    • Drafts 저장/삭제 후 타임라인 돌아오면 배지 숫자 갱신
    • Pull-to-refresh 전 화면 (+ 빈 상태의 refresh 버튼)
    • 오프라인 상태 진입 → 에러 UI + retry
    • 중복 post 시나리오 (원본 + repost wrapping) — 한 번만 표시
    • 낙관적 share/reaction 실패 시 revert
    • 앱 cold start 체감 (runBlocking 제거 효과) — 로그인 상태에서 첫 Activity 표시 지연 감소
    • Settings 의 "Clear Cache" 탭했을 때 UI freeze 없음

🤖 Generated with Claude Code

@malkoG
Copy link
Copy Markdown
Collaborator

malkoG commented Apr 13, 2026

혹시 리베이스 해주실 수 있나요?

@dalinaum
Copy link
Copy Markdown
Contributor Author

자정 근처에 확인해볼게요.

dalinaum and others added 10 commits April 14, 2026 09:49
Lay the backend surface for migrating feeds to Paging 3. Follow-up commits
will convert Notifications, Timeline, PostDetail, Explore, and Profile
screens to consume Flow<PagingData<T>>.

Paging infra (data/paging/):
- CursorPagingSource<T> + cursorPager factory. PagingConfig defaults tuned
  from Network Inspector findings (~15 KB per post): pageSize=20,
  prefetchDistance=20 (one full page ahead), initialLoadSize=30 (1.5 pages),
  placeholders disabled.
- PostOverlay + PostOverlayStore + applyOverlay(s) for optimistic share /
  reaction mutations without invalidating the PagingSource.
- Flow<PagingData<Post>>.distinctByEffectiveId() — drops duplicates by
  displayed post id (sharedPost.id ?: id). Layered on top of the existing
  repository-level outer-id dedup from PR hackers-pub#83; this one handles repost
  pairs (different outer ids, same sharedPost.id) at the paging boundary.

Repository adapters (extension fns on HackersPubRepository):
- notificationsPage, personalTimelinePage, publicTimelinePage,
  localTimelinePage, postRepliesPage, actorPostsPage, actorNotesPage,
  actorArticlesPage — one-liners so ViewModels can call
  cursorPager { repository.fooPage(it) } directly.

New repository methods:
- getPostReplies(postId, after): paginates replies independently of the
  main post body (separate PostReplies query).
- getActorPosts(handle, after): lightweight POSTS-tab query that avoids
  re-fetching the actor header per page (mirrors getActorNotes /
  getActorArticles).
- Both wrap their response-mapping block in withContext(Dispatchers.Default)
  — Apollo .execute() already dispatches network off-Main, but the
  subsequent nested Post construction + Instant.parse is CPU-bound and
  would otherwise run on the caller's dispatcher (viewModelScope defaults
  to Main.immediate). Existing methods are unchanged; they'll adopt the
  same pattern as they're exercised by paging callers.

GraphQL operations:
- Add ActorPosts query (lightweight POSTS-tab variant).
- Add PostReplies query (replies-only subset of PostDetail).
- PostFields / SharedPostFields mentions(first: 20) unchanged.

Models:
- Annotate every data class with @immutable (including new ArticleDraft,
  AccountLink, ActorField added post-branch) so Compose skip analysis
  treats feed types as stable.

Dependencies:
- androidx.paging:paging-runtime-ktx + paging-compose 3.3.5
- androidx.paging:paging-testing, mockk 1.13.12, turbine 1.1.0,
  kotlinx-coroutines-test for ViewModelTest groundwork.
- composeCompiler reports block added to build.gradle.kts in commented
  form — uncomment to regenerate stability reports when debugging
  recomposition.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Convert NotificationsViewModel to expose Flow<PagingData<Notification>> via
cursorPager + the notificationsPage repository adapter. Remove the manual
pagination UiState (hasNextPage, endCursor, loadMore, refresh).

NotificationsScreen consumes LazyPagingItems with collectAsLazyPagingItems
and gates loading/error/empty UI on items.loadState.refresh. Branch order:
Error+empty -> NotLoading+empty+endOfPaginationReached -> empty (pre-load
Loading/NotLoading) -> content. This ordering prevents the initial-load
flicker where pre-Loading NotLoading frames briefly render the empty state.

Empty-state refresh button from PR hackers-pub#91 preserved via
ErrorMessage(onRefresh = { items.refresh() }). markAsSeen triggers from a
LaunchedEffect keyed on items.loadState.refresh once the initial refresh
settles with items present.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Convert TimelineViewModel to expose Flow<PagingData<Post>> via
cursorPager + distinctByEffectiveId + PostOverlayStore. Optimistic share
and reaction mutations now route through overlayStore.mutate / .clear
so the PagingSource stays valid across mutations.

TimelineScreen consumes LazyPagingItems with collectAsLazyPagingItems.
LoadState branch order (Error+empty -> NotLoading+endOfPaginationReached
-> empty -> content) prevents initial-load flicker. The postedAt and
tabRetapped LaunchedEffects hook into snapshotFlow on items.loadState
instead of the old isRefreshing flag.

Preserved from main:
- PR hackers-pub#97 draft badge: TimelineUiState.draftCount + init { loadDraftCount() }
  + LaunchedEffect(Unit) { viewModel.loadDraftCount() } in the Screen so
  returning from the Drafts screen refreshes the badge count.
- PR hackers-pub#93 recommended actors button placed between article and settings.
- PR hackers-pub#91 empty-state refresh button — wired to items.refresh() (paging)
  instead of the old viewModel.refresh().
- All top-bar callbacks (onComposeArticleClick, onRecommendedActorsClick,
  onSettingsClick, onComposeClick, etc.) retain their signatures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PostDetailViewModel now exposes replies as Flow<PagingData<Post>> built
from cursorPager + postRepliesPage + distinctByEffectiveId. The main
post, reactionGroups, shares/quotes sheets, delete flow, and translation
state remain in UiState since they are single-instance optimistic
updates that don't benefit from the overlay pattern.

PostDetailScreen keeps PR hackers-pub#89's PostDetailStateDispatch as the loading/
error/content gate. collectAsLazyPagingItems is called inside the content
lambda so the replies pager is tied to the post lifecycle. Pull-to-refresh
invokes both viewModel.refresh() (main post) and replies.refresh().

PostDetailContent signature changes: replies parameter is now
LazyPagingItems<Post>, replacing List<Post> + hasMoreReplies +
isLoadingMoreReplies + onLoadMoreReplies. loadState.append drives the
in-list loading footer.

PostDetailContentTest.kt updated to provide replies via
flowOf(PagingData.from(...)).collectAsLazyPagingItems().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ExploreViewModel.posts is now a Flow<PagingData<Post>> driven by a
selectedTab StateFlow through flatMapLatest — switching between LOCAL
and GLOBAL cancels the previous pager and starts a fresh one, so stale
emissions never leak across tabs. cachedIn is placed inside flatMapLatest
per Paging 3 guidance. Overlays are combined after the pager switch so
optimistic share/reaction updates persist across tab changes within the
same post id.

Manual scroll-based loadMore, refresh state flags, and endCursor tracking
are removed; LazyPagingItems + prefetchDistance drive pagination.

ExploreScreen uses collectAsLazyPagingItems with the six-case LoadState
switch (Error+empty, NotLoading+endOfPaginationReached+empty, empty
else (pre-Loading frame), and content) to prevent initial-load flicker.
Empty-state refresh button from PR hackers-pub#91 preserved via
ErrorMessage(onRefresh = { items.refresh() }).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ProfileViewModel exposes three independent Flow<PagingData<Post>>
(postsTab, notesTab, articlesTab), each built from
cursorPager + actor*Page repository adapter + distinctByEffectiveId. A
shared PostOverlayStore is layered on all three flows, so an optimistic
react/share on one tab propagates across the others without an extra
network round trip (replaces the old updatePostInAllTabs helper).

Actor header state (bio, fields, account links, follow/block/isViewer)
remains in UiState since it's one-shot and needs direct optimistic
updates that don't fit the overlay pattern.

Preserved from PR hackers-pub#88:
- ProfileTab enum and tab selection state
- ProfileHeader, ProfileTabBar, ProfileAttachments, ProfileFields
  composables copied verbatim into the new Screen layout.

Preserved from PR hackers-pub#91:
- ErrorMessage(onRefresh = { items.refresh() }) on empty state.

Dead-fields cleanup (from the original d160bfe commit, applied now that
Paging 3 replaces the imperative POSTS pagination):
- operations.graphql: ActorByHandle no longer selects posts(first, after)
  or declares \$after — actor*Page queries paginate instead.
- HackersPubRepository.getProfile loses its postsAfter parameter and
  stops mapping actor.posts.
- Models.ProfileResult drops posts, hasNextPage, endCursor. fields and
  accountLinks remain.
- ProfileScreenTest fixtures updated for the shorter ProfileResult.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Media thumbnails (53197d9):
- MediaImage uses thumbnailUrl ?: url for feed grid / card preview
  (smaller payload, faster decode: ~2000x2000 source displayed at
  ~150 dp used to pull ~500 KB of data per image).
- QuotedPostPreview first-media thumbnail uses the same fallback.
- Full-resolution url is still used by MediaPreviewDialog (tap-to-zoom),
  DownloadManager, and external-share intents.

ViewModel tests (e7a98f8):
- testutil/MainDispatcherRule: swaps Dispatchers.Main for a
  TestDispatcher for the test lifetime.
- data/paging/PostOverlayTest: pure-function coverage for
  applyOverlay / applyOverlays (direct vs sharedPost propagation,
  partial overlay, clamp-at-zero).
- ui/screens/{timeline,explore,postdetail,profile,notifications}/
  *ViewModelTest: picker visibility, share/unshare/toggleReaction
  repository call verification, sharedPost target id handling,
  optimistic-mutation failure paths, tab selection state, delete flow,
  follow/unfollow/block routing, refresh re-fetch. Adjusted to the
  post-migration ViewModel shapes: selectedTab is a standalone
  StateFlow (not a UiState field) on Explore and Profile;
  ProfileResult no longer has posts/hasNextPage/endCursor.
- TimelineViewModelTest stubs repository.getArticleDrafts() since
  the init block now calls loadDraftCount (hackers-pub#97).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two runBlocking calls were blocking the Main thread during app startup
(MT-1, MT-2 from the stutter audit):

1. AppModule's Authorization Interceptor called
   runBlocking { sessionManager.sessionToken.first() } on every request,
   which meant the interceptor paid a DataStore file-read cost (blocking
   the caller's thread) each time. DataStore itself dispatches its I/O on
   Dispatchers.IO, but runBlocking pins the caller until that I/O
   completes - so a request initiated from viewModelScope on Main
   (Main.immediate) stalled the UI thread.

2. MainActivity.requestNotificationPermissionIfNeeded() called
   runBlocking { sessionManager.isLoggedIn.first() } in the onCreate
   critical path, delaying first-frame rendering.

Fixes:

- SessionManager now exposes sessionTokenState: StateFlow<String?> and
  isLoggedInState: StateFlow<Boolean>, both backed by stateIn on an
  application-scoped coroutine (Eagerly). First emit populates them
  asynchronously; the interceptor and activity read .value snapshots
  with zero blocking.
- AppModule's Authorization Interceptor reads
  sessionManager.sessionTokenState.value; pre-login the value is null
  and no Authorization header is attached (login API doesn't need one,
  so behavior is unchanged).
- MainActivity.requestNotificationPermissionIfNeeded() wraps the login
  check in lifecycleScope.launch { ... } so onCreate returns without
  waiting on DataStore.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
calculateCacheSize and clearCache previously launched on
viewModelScope (Main.immediate) and synchronously walked the cache
directory + deleted files on the Main thread. With a populated cache,
both operations could stall frame delivery for tens to hundreds of ms
— observable as a stutter when opening Settings or tapping
Clear Cache.

Wrap the blocking bodies in withContext(Dispatchers.IO) { … }. The
StateFlow updates after each operation still land on the caller's
dispatcher (Main), which is correct for UI state. Apollo store
operations (apolloStore.clearAll()) go inside the same IO block
since they touch the SQLite-backed normalized cache.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…e time)

Three per-recomposition CPU-bound computations were running on every
frame of a recomposition, contributing to scroll/nav stutters (MT-4,
MT-5, MT-7 from the stutter audit):

1. PostDetailScreen's DateTimeFormatter.ofPattern(...).withZone(...)
   was allocated inside the composable body. Pattern compile + zone
   lookup on every recomposition. Wrapped in remember { }.

2. ReactionPicker computed two collection operations - .filter +
   .associateBy for the emoji -> group map, and .filter + .sortedWith
   for the selected groups - on every recomposition. Wrapped each in
   remember(reactionGroups) { } so they only re-compute when the
   source list changes.

3. NotificationsScreen's NotificationItem called formatRelativeTime
   (Duration.between + Instant.now + when-branch) per item per
   recomposition. Wrapped the call site with
   remember(notification.created) { formatRelativeTime(...) }. Labels
   no longer tick in real time, but notifications scroll/interact
   will trigger fresh composition often enough in practice.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@dalinaum dalinaum force-pushed the feat/paging3-and-immutable branch from 1d0b9d9 to e501343 Compare April 14, 2026 01:34
@dalinaum dalinaum marked this pull request as draft April 14, 2026 01:36
@dalinaum
Copy link
Copy Markdown
Contributor Author

이왕 컨플릭난 김에 더 수정을 요청했는데 범위가 커서 코드를 좀 살펴보고 테스트를 해보고 드래프트를 풀겠습니다. @malkoG

@dalinaum dalinaum changed the title Migrate paginated feeds to Paging 3 + @Immutable, reduce payload, add ViewModel tests Migrate paginated feeds to Paging 3 + @Immutable + fix Main-thread stutters Apr 14, 2026
dalinaum and others added 4 commits April 14, 2026 11:21
Fixes the Notifications spinner freeze reported on first entry. The
Paging 3 PagingSource.load coroutine is launched on Main.immediate via
cachedIn(viewModelScope), so any CPU work between Apollo's .execute()
completing and the Result returning ran on Main. For Notifications the
per-edge toNotification() does Instant.parse + nested actors/post
mapping — a 20-item initial page accumulated ~50-200 ms of Main-thread
CPU, long enough that the loading spinner animation visibly froze.

Wrap the response-mapping block (not the whole function) in
withContext(Dispatchers.Default) for every repository method that
constructs our domain models from Apollo responses:

- getPublicTimeline, getLocalTimeline, getPersonalTimeline
- getNotifications  <-- direct cause of the frozen-spinner symptom
- getPostDetail
- getProfile
- getActorArticles, getActorNotes

getPostReplies and getActorPosts already had this pattern applied at
migration time; the existing methods were deferred pending profiling
(MT-6 in the stutter audit). User report confirmed the hit, so apply
uniformly.

Notes:
- Apollo .execute() stays outside the withContext block — it already
  dispatches network to OkHttp's thread pool, so wrapping it would only
  add unnecessary context switches.
- response.hasErrors() branch stays outside too; just accessing the
  error object is cheap.
- Existing hackers-pub#83 defensive .distinctBy { it.id } is preserved inside the
  mapping block.
- For methods with an early `return Result.failure(...)` from a null
  check, replaced with `return@withContext Result.failure(...)` so the
  control flow stays inside the suspending lambda.
- Unit tests use runTest + StandardTestDispatcher and stub the
  repository with mockk, so the dispatcher change is transparent.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The previous "one-full-page-ahead" prefetch (20) combined with an
inflated initialLoadSize (30) caused cascading prefetch right after
first render. Because the server caps every page at first: 20, an
initial load actually returned 20 items — but PagingConfig had asked
for 30, which Paging treated as an under-filled window and immediately
queued an append. With prefetchDistance=20 and only 20 items loaded,
any small scroll satisfied `distance-from-end < prefetchDistance`, so
pages 2, 3, 4… fired back-to-back before the user reached the tail.

New values:
- prefetchDistance = 5. Roughly half a viewport; the next page fetches
  when the user is genuinely approaching the tail instead of on the
  first render. The Paging library guidance is "a bit more than what's
  visible" — notifications / timeline show 3-8 items at once, so 5 is
  the right shoulder.
- initialLoadSize = 20. Matches pageSize and the server's fixed first
  page, eliminating the desired-vs-delivered mismatch that forced an
  immediate append on first load.
- pageSize = 20. Unchanged (server constraint).
- enablePlaceholders = false. Unchanged (no skeleton UI).

This complements the previous commit's Dispatchers.Default offloading:
that commit stops any single page's mapping from freezing the UI; this
commit stops us from firing extra pages that don't benefit the user
and multiply server load.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
HtmlContent and CodeBlockView previously parsed inline during composition
(remember { parseHtmlToAnnotatedString(...) } / remember { parseShikiCodeBlock(...) }).
The memoization helped on revisits but the first composition of each
newly-visible LazyColumn item paid the full parse cost on Main. For rich
posts (headings, lists, mentions, code blocks) this is 10-50 ms per item;
during fast scroll the costs accumulate into visible stutters.

Switch to a two-track approach:

- Process-level LruCache keyed by (html, themed colors) so once an item
  is parsed anywhere in the app (Timeline / Explore / Profile / PostDetail
  / Notifications) it stays hot across screen switches.
- Sync parse during composition only when the cache already has the
  result or the html is short enough (< 500 chars for bodies,
  < 300 chars for code blocks) that inline parsing is effectively free.
- Otherwise render an empty AnnotatedString placeholder on the first
  frame and parse on Dispatchers.Default via produceState; the Text
  populates on the next frame. During scroll this keeps the frame
  callback unblocked so the LazyColumn can keep rendering without
  stalling.

Cache sizes (256 bodies, 128 code blocks) cover the active set across
bottom-nav tabs comfortably; eviction falls back to reparse, which stays
off Main. The cache key includes theme colors so dark-mode toggling
correctly reparses instead of serving stale highlighting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@dalinaum dalinaum marked this pull request as ready for review April 14, 2026 17:14
The generated Apollo types mark `iri` and `type` as non-null on the
relevant fragments, and `toPost()` never returns null — it always
constructs a `Post` from the fields it's given. The prior
`mapNotNull { ... toPost(...) }` and `?.toString()` / `type?.toString()`
were therefore dead safe-guards that only served to obscure the real
shape of the data.

- 9 `.mapNotNull { edge -> edge.node.postFields.toPost(...) }` calls
  simplified to `.map { ... }` across getPublicTimeline,
  getLocalTimeline, getPersonalTimeline, searchPosts, getPostDetail's
  replies block, getPostReplies, getActorPosts, getActorArticles,
  getActorNotes, getPostQuotes.
- `iri?.toString()` -> `iri.toString()` in the two `toPost()`
  overloads.
- `mediaType = type?.toString()` -> `mediaType = "$type"` in `toMedia()`.

No functional change; just makes static types match runtime.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* under-filled on first load.)
* - `enablePlaceholders = false` — existing UI has no skeleton support.
*/
fun <T : Any> cursorPager(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

T : Any 가 들어간 코드도 이후의 변경사항으로서 이슈 파일링을 해두는게 좋겠죠???

@malkoG malkoG merged commit f5a1051 into hackers-pub:main Apr 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants