Skip to content

긴 목록의 viewport·identity 계약 — U04

실행 이슈 #1535 · 작업 기록. 적용 대상은 release/v0.18.0이며 Production 반영과 구분한다.

목록·스크롤 소유

화면canonical key실제 세로 스크롤
모바일 종목 피커synonym row.keysheet-body
데스크톱 종목 피커synonym row.keydk-pane-scroll, 높이 300px
모바일 검색·홈 종목 검색synonym row.keyscreen / sheet-body
검색의 선택 종목 세트 피드sessionExerciseId, 없으면 기존 rec.id/sessionIdscreen
A14 종목 훈련 이력sessionExerciseId인 view row.keysheet-body
모바일 피드session.idscreen
데스크톱 피드session.iddocument

ViewportList는 가장 가까운 overflow-y auto/scroll 조상을 사용하고, 없으면 window를 사용한다. 별도의 중첩 세로 scroller를 만들지 않는다. 목록 시작 위치는 scrollMargin으로 전달한다. U07의 PR board 가로 스크롤·U02/U03 편집 입력은 이 목록 전환 대상이 아니다.

DOM과 데이터는 별개

  • 60개 이하는 일반 document flow이며 virtualizer는 disabled다. 60개 경계를 넘어도 같은 canonical DOM 경계를 사용한다. 긴 목록만 설치된 @tanstack/react-virtual@3.13.19의 실측 높이·viewport와 앞뒤 5행 overscan을 사용한다.
  • 포커스를 받은 최근 행 1개, 복원/알림의 일시적 대상은 추가로 남을 수 있다. 방문한 페이지 전체를 계속 마운트하지 않는다. 현재 로드 목록의 순서·크기는 role=list/listitem과 aria-posinset/aria-setsize로 표현한다. 이는 서버의 전체 이력 건수 선언이 아니다.
  • 페이지 요청, keyset cursor, hasMore/loading/exhausted, owner resource, 10년 창과 setsTruncated 표시의 소유는 변경하지 않는다. feedResource의 기존 200개 창도 유지한다. 표현 테스트의 300행 피드는 이 상한보다 큰 stress fixture다.
  • “더 보기”는 기존 페이지 하나를 요청한다. 가상화는 데이터 선로딩·자르기·서버 쿼리 변경을 하지 않는다. 데이터 캐시와 virtualizer의 크기 측정 캐시는 live DOM과 별도로 남는다.

항목 identity와 편집 경계

immutable resource 객체를 입력으로 받는 exerciseHistoryRowView, buildMobileFeedPosts, lgDesktopProfileFeed는 WeakMap으로 항목 투영을 재사용한다. 페이지 추가 시 기존 source 객체는 같은 view 객체를 받고, 수정된 source는 새 view로 투영한다. 새 source를 만들지 않고 입력을 제자리 변경하는 것은 이 계약에 맞지 않는다. 약한 키는 이전 owner 객체를 캐시 때문에 붙잡지 않는다.

키는 표시 이름·배열 index 대신 기존 canonical ID다. 목록은 renderItem이 바뀌지 않고 item/index가 같으면 행을 memo로 재사용한다. 배열 투영·key/index 구성은 여전히 로드 건수에 비례한다. 별도 전역 데이터 캐시나 equality 엔진을 추가하지 않는다.

U02/U03의 편집기 행은 가상화하지 않는다. 상세·초안·댓글 상태는 기존 상위 feature/controller가 소유하고, 목록 행이 DOM 밖으로 나간다고 상세나 편집 저장 상태를 버리지 않는다.

위치·포커스·크기 변경

  • 데이터 변경 전 React의 getSnapshotBeforeUpdate에서 보이는 canonical 행과 viewport 내 위치를 기록한다. 페이지 추가·수정·삭제 후 같은 key를 복원하며, 그 key가 사라졌으면 같은 순서의 다음 행, 끝이면 직전 행을 기준으로 한다.
  • 활성 행이 삭제되면 같은 위치의 다음 행(끝이면 직전 행) control로 포커스를 옮긴다. 기존 행이 살아 있으면 해당 DOM/control의 identity를 유지한다.
  • 화면 밖에 포커스 행이 있어도 Tab/Shift+Tab은 canonical 인접 행의 첫/마지막 control로 이동한다. 마지막/첫 항목의 바깥 이동은 브라우저 기본 동작에 맡긴다. 행 내부의 control 순서·Enter/Space 선택은 기존 제품에 남는다.
  • 검색 질의 변경은 resetKey로 스크롤과 남겨 둔 포커스 기준을 초기화한다. retained tab/상세 overlay 왕복은 기존 화면 생명주기와 함께 유지한다. 완전한 화면 unmount를 넘는 전역 스크롤 저장소는 추가하지 않았다.
  • 행 높이는 실제 DOM을 측정한다. 폭이 바뀌면 기존 크기 캐시를 재측정한다. 폰트 loading 전에 기준을 잡아 loadingdone에서 재측정·복원한다. 가변 높이의 위치 확정은 설치 virtualizer의 scrollToIndex 보정에 맡기며 별도 timeout/재시도 루프를 만들지 않는다.
  • 알림의 focusSessionId가 아직 로드되지 않았으면 기다리고, 데이터에 나타나면 viewport 밖의 해당 key를 렌더·이동한다. UI는 기존 1.6초 highlight와 소비 콜백을 유지한다.

검증과 후속 입력

앱의 tests/browser/u04는 실제 제품 component/CSS/host를 사용한다. 끝까지·역방향 이동, canonical 선택, 임계 페이지, 삭제·수정, 상세 왕복, 키보드, 폰트·폭, owner·retained tab, 페이지 완전성을 검사한다. tests/react/viewportIdentity.test.mjs는 투영 identity/수정 갱신을 검사한다. 기존 index-key 소스 단언은 동작으로 교체하고 audit pending 사유를 남겼다.

U05는 새 공통 ViewportList 정적 import와 HostFrames 공통 chunk 증가를 고려한다. U06은 이 계약의 keyboard/가변 높이/overlay 입력을 받되, 이 harness의 native dialog 왕복을 실제 앱 라우팅·native 실기기·screen reader·safe area 전체 완료로 집계하지 않는다. R04는 동일 workload 전후 자료와 증가한 누적 렌더 비용도 함께 받는다. R01에는 useListWindow 삭제·네 소비자 이전·런타임 참조 제거가 퇴역 근거다.