Skip to content

fix: メッセージスクロール周りの改修#5004

Open
yas-ako wants to merge 20 commits into
masterfrom
fix/message-scroller-layout-shift
Open

fix: メッセージスクロール周りの改修#5004
yas-ako wants to merge 20 commits into
masterfrom
fix/message-scroller-layout-shift

Conversation

@yas-ako

@yas-ako yas-ako commented Dec 31, 2025

Copy link
Copy Markdown
Member

概要

  • refactor: useMessageScroller を導入してメッセージスクロール周りのロジックを統合・リファクタリング
  • fix: 初回ロード時にスクロール位置が末尾からわずかにズレる問題を修正
  • fix: WebKit 環境で画像サイズが小さく表示されていた問題を修正
  • fix: コードブロックが含まれる場合もメッセージの高さを監視する
  • fix: ResizeObserver の登録を待ってから追加の要素を描画する
  • feat: 初回ロード時にスケルトンを表示する
  • feat: スクロール速度に応じてメッセージを読み込むまでの閾値を動的に調整する

なぜこの PR を入れたいのか

PR を出す前の確認事項

  • (機能の追加なら)追加することの合意がチームで取れている
    • 取れていない場合はチェックを外して PR にすれば OK
  • 動作確認ができている
  • 自分で一度コードを眺めて自分的に問題はなさそう

Summary by CodeRabbit

Release Notes

  • 新機能

    • メッセージ初期読み込み時にスケルトンローディングUIを追加
    • パフォーマンス向上のための遅延レンダリングを導入
  • スタイル

    • スクロール動作およびオーバースクロール挙動を改善
  • テスト

    • ユーティリティテストを更新

@yas-ako yas-ako requested a review from a team December 31, 2025 13:29
@github-actions

Copy link
Copy Markdown

@codecov

codecov Bot commented Dec 31, 2025

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 62.64%. Comparing base (f5c3b4d) to head (dc48d47).
⚠️ Report is 1 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #5004      +/-   ##
==========================================
+ Coverage   62.60%   62.64%   +0.03%     
==========================================
  Files         108      108              
  Lines        3097     3100       +3     
  Branches      630      630              
==========================================
+ Hits         1939     1942       +3     
  Misses       1048     1048              
  Partials      110      110              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@uni-kakurenbo uni-kakurenbo force-pushed the fix/message-scroller-layout-shift branch from d20401c to d0690a0 Compare January 2, 2026 02:19
@uni-kakurenbo uni-kakurenbo marked this pull request as ready for review January 2, 2026 06:45
@uni-kakurenbo uni-kakurenbo added the bug Something isn't working label Jan 2, 2026
@uni-kakurenbo uni-kakurenbo moved this from Inbox to In review in traQ Client Jan 2, 2026
@uni-kakurenbo uni-kakurenbo force-pushed the fix/message-scroller-layout-shift branch from b4077b5 to b65804b Compare January 2, 2026 07:10
@uni-kakurenbo uni-kakurenbo mentioned this pull request Jan 2, 2026
2 tasks
@uni-kakurenbo uni-kakurenbo changed the title メッセージを読み込んだ際にスクロール位置が下にずれてしまう問題を修正する(draft) fix: メッセージを読み込んだ際にスクロール位置が下にずれてしまう問題を修正 Jan 2, 2026
@uni-kakurenbo uni-kakurenbo force-pushed the fix/message-scroller-layout-shift branch 4 times, most recently from 789f4cd to cfb4b02 Compare January 3, 2026 01:53
@uni-kakurenbo uni-kakurenbo changed the title fix: メッセージを読み込んだ際にスクロール位置が下にずれてしまう問題を修正 fix: メッセージスクロール周りの改修 Jan 3, 2026
@uni-kakurenbo uni-kakurenbo force-pushed the fix/message-scroller-layout-shift branch 11 times, most recently from cdfff3d to a3cdf7a Compare January 3, 2026 16:04
@uni-kakurenbo uni-kakurenbo force-pushed the fix/message-scroller-layout-shift branch 7 times, most recently from 1dd991e to fcbc5d7 Compare March 26, 2026 10:21
yas-ako and others added 20 commits March 26, 2026 19:27
@uni-kakurenbo uni-kakurenbo force-pushed the fix/message-scroller-layout-shift branch from fcbc5d7 to dc48d47 Compare March 26, 2026 10:27
@uni-kakurenbo uni-kakurenbo marked this pull request as ready for review March 26, 2026 10:35

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/components/Main/MainView/MessagesScroller/composables/useMessagesFetcher.ts (1)

214-222: ⚠️ Potential issue | 🟠 Major

isInitialLoad が失敗経路で解除されない可能性があります

Line 216 で true にした後、初回ロード処理が例外終了すると false に戻らず、初期ロードUIが張り付くリスクがあります。init 側で finally による解除を持たせると安全です。

💡 提案修正(失敗時も必ず初期ロード状態を解除)
-const init = () => {
+const init = async () => {
   resetRenderedContent()
   isInitialLoad.value = true
-  if (props.entryMessageId) {
-    onLoadAroundMessagesRequest(props.entryMessageId)
-  } else {
-    isReachedLatest.value = true
-    onLoadLatestMessagesRequest()
-  }
+  try {
+    if (props.entryMessageId) {
+      await onLoadAroundMessagesRequest(props.entryMessageId)
+    } else {
+      isReachedLatest.value = true
+      await onLoadLatestMessagesRequest()
+    }
+  } finally {
+    isInitialLoad.value = false
+  }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/components/Main/MainView/MessagesScroller/composables/useMessagesFetcher.ts`
around lines 214 - 222, The init function sets isInitialLoad.value = true but
never guarantees it will be reset if onLoadAroundMessagesRequest or
onLoadLatestMessagesRequest throw; wrap the conditional load calls in a
try/finally inside init (using the existing init, resetRenderedContent,
isInitialLoad, onLoadAroundMessagesRequest and onLoadLatestMessagesRequest
identifiers) and set isInitialLoad.value = false in the finally block so the
initial-load UI is cleared regardless of success or failure.
src/components/Main/MainView/MessageElement/composables/useElementRenderObserver.ts (1)

39-68: ⚠️ Potential issue | 🟡 Minor

ResizeObserver のクリーンアップが不足しています。

コンポーネントがアンマウントされた際に resizeObserver.disconnect() が呼ばれないため、メモリリークの可能性があります。

🛡️ 修正案
+import { onUnmounted, unref, watch, watchEffect } from 'vue'
-import { unref, watch, watchEffect } from 'vue'

// ... 既存のコード ...

  watch(
    () => route.path,
    () =>
      // パス変更でunobserve
      // vue-routerのインスタンス再利用対策
      bodyRef.value ? resizeObserver.unobserve(bodyRef.value) : undefined
  )
+
+  onUnmounted(() => {
+    resizeObserver.disconnect()
+  })
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/components/Main/MainView/MessageElement/composables/useElementRenderObserver.ts`
around lines 39 - 68, The ResizeObserver created as resizeObserver is never
disconnected, risking a leak; ensure you call resizeObserver.disconnect() during
cleanup—add a call to disconnect inside the stop() path (where you stop
observing initial renders) and also register onBeforeUnmount/cleanup in the
composable to call resizeObserver.disconnect() so the observer is removed when
the component using useElementRenderObserver unmounts; reference resizeObserver,
stop(), prevState, emit, bodyRef, isEntryMessage and messageId to keep the
existing logic (entryMessageLoaded / changeHeight emits) intact while adding the
disconnects.
🧹 Nitpick comments (3)
src/components/Main/MainView/MessagesScroller/MessagesScroller.vue (1)

310-313: handleScroll が初期化時にのみ評価される問題。

enableProactiveLoading は computed ですが、handleScroll はセットアップ時に一度だけ評価されます。isMobile が実行時に変化した場合(例:ウィンドウリサイズ)、handleScroll の throttle/debounce 設定は更新されません。

実際に isMobile が変化するシナリオが稀であれば、現状でも問題ありませんが、ドキュメントコメントがあると意図が明確になります。

♻️ コメント追加案
+// Note: enableProactiveLoading の値はセットアップ時に固定される
+// (実行時に isMobile が変化しても handleScroll は更新されない)
 const handleScroll = (enableProactiveLoading.value ? throttle : debounce)(
   enableProactiveLoading.value ? 24 : 200,
   requestLoadMessages
 )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/Main/MainView/MessagesScroller/MessagesScroller.vue` around
lines 310 - 313, handleScroll is created once in setup using
enableProactiveLoading (computed) so its throttle/debounce choice won't update
if isMobile/enableProactiveLoading changes at runtime; either add a short doc
comment above the handleScroll definition stating this one-time evaluation is
intentional (because isMobile changes are rare) or make it reactive by
recreating handleScroll when enableProactiveLoading changes (e.g., replace the
one-off call with a computed or a watch on enableProactiveLoading that reassigns
handleScroll using throttle/debounce and rebinds listeners), referencing
handleScroll, enableProactiveLoading, isMobile, requestLoadMessages, throttle,
and debounce.
src/components/Main/MainView/MessagesScroller/composables/useMessageScroller.ts (2)

103-116: コメントの意図を確認してください。

Line 103-104 のコメント // XXX: 追加時にここは0になる について、ids.length - prevIds.length === 0 は「配列長が変わらない」ケースを示しますが、これは通常「追加」ではなく「置換」や「更新」のケースです。

コメントの意図を確認し、必要であれば修正するか、XXX を解消してください。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/components/Main/MainView/MessagesScroller/composables/useMessageScroller.ts`
around lines 103 - 116, The inline XXX comment "// XXX: 追加時にここは0になる" is
misleading because the branch `if (ids.length - prevIds.length === 0)` actually
covers cases where the array length did not change (updates/replacements), not
additions; update the comment to accurately describe that this block handles
"no-length-change" scenarios (e.g., replacements/updates), or remove XXX if
obsolete, and ensure any wording references the variables used here (`ids`,
`prevIds`, `rootRef`, `state.height`, `newHeight`) so future readers understand
that when lengths are equal we preserve scroll position unless already near
bottom.

26-78: onChangeHeight 内の冗長なnullチェック。

Line 68 の if (!rootRef.value) return は、Line 27 で既にチェック済みのため冗長です。ただし、await defer() の後なので、その間に rootRef.valuenull になる可能性は理論上あります。現状のままで問題ありませんが、コメントがあると意図が明確になります。

♻️ コメント追加案
      // 末尾付近を閲覧しているときは,末尾を保つ
      if (state.height - scrollBottom <= 50) {
-        if (!rootRef.value) return
+        // defer() 中にアンマウントされた可能性があるため再チェック
+        if (!rootRef.value) return

        // 複数の onChangeHeight が同フレーム内に複数回呼ばれる場合があるので,
        // すべての更新を待ってからスクロールする
        await defer()
        rootRef.value.scrollTo({ top: rootRef.value.scrollHeight })
      }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/components/Main/MainView/MessagesScroller/composables/useMessageScroller.ts`
around lines 26 - 78, Keep the guard `if (!rootRef.value) return` inside
onChangeHeight (after the await defer()) but add a concise comment explaining
why it's necessary: note that rootRef.value was checked at the top of
onChangeHeight but can become null during the await defer(), so the second
null-check prevents runtime errors before calling
rootRef.value.scrollTo/getBoundingClientRect; reference the onChangeHeight
function, rootRef, and defer in the comment for clarity.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In
`@src/components/Main/MainView/MessageElement/composables/useElementRenderObserver.ts`:
- Around line 39-68: The ResizeObserver created as resizeObserver is never
disconnected, risking a leak; ensure you call resizeObserver.disconnect() during
cleanup—add a call to disconnect inside the stop() path (where you stop
observing initial renders) and also register onBeforeUnmount/cleanup in the
composable to call resizeObserver.disconnect() so the observer is removed when
the component using useElementRenderObserver unmounts; reference resizeObserver,
stop(), prevState, emit, bodyRef, isEntryMessage and messageId to keep the
existing logic (entryMessageLoaded / changeHeight emits) intact while adding the
disconnects.

In
`@src/components/Main/MainView/MessagesScroller/composables/useMessagesFetcher.ts`:
- Around line 214-222: The init function sets isInitialLoad.value = true but
never guarantees it will be reset if onLoadAroundMessagesRequest or
onLoadLatestMessagesRequest throw; wrap the conditional load calls in a
try/finally inside init (using the existing init, resetRenderedContent,
isInitialLoad, onLoadAroundMessagesRequest and onLoadLatestMessagesRequest
identifiers) and set isInitialLoad.value = false in the finally block so the
initial-load UI is cleared regardless of success or failure.

---

Nitpick comments:
In
`@src/components/Main/MainView/MessagesScroller/composables/useMessageScroller.ts`:
- Around line 103-116: The inline XXX comment "// XXX: 追加時にここは0になる" is
misleading because the branch `if (ids.length - prevIds.length === 0)` actually
covers cases where the array length did not change (updates/replacements), not
additions; update the comment to accurately describe that this block handles
"no-length-change" scenarios (e.g., replacements/updates), or remove XXX if
obsolete, and ensure any wording references the variables used here (`ids`,
`prevIds`, `rootRef`, `state.height`, `newHeight`) so future readers understand
that when lengths are equal we preserve scroll position unless already near
bottom.
- Around line 26-78: Keep the guard `if (!rootRef.value) return` inside
onChangeHeight (after the await defer()) but add a concise comment explaining
why it's necessary: note that rootRef.value was checked at the top of
onChangeHeight but can become null during the await defer(), so the second
null-check prevents runtime errors before calling
rootRef.value.scrollTo/getBoundingClientRect; reference the onChangeHeight
function, rootRef, and defer in the comment for clarity.

In `@src/components/Main/MainView/MessagesScroller/MessagesScroller.vue`:
- Around line 310-313: handleScroll is created once in setup using
enableProactiveLoading (computed) so its throttle/debounce choice won't update
if isMobile/enableProactiveLoading changes at runtime; either add a short doc
comment above the handleScroll definition stating this one-time evaluation is
intentional (because isMobile changes are rare) or make it reactive by
recreating handleScroll when enableProactiveLoading changes (e.g., replace the
one-off call with a computed or a watch on enableProactiveLoading that reassigns
handleScroll using throttle/debounce and rebinds listeners), referencing
handleScroll, enableProactiveLoading, isMobile, requestLoadMessages, throttle,
and debounce.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8406740b-1c96-4b8b-b383-45254486cfa5

📥 Commits

Reviewing files that changed from the base of the PR and between f5c3b4d and dc48d47.

📒 Files selected for processing (17)
  • src/components/Main/MainView/ChannelView/ChannelViewContent/ChannelViewContentMain.vue
  • src/components/Main/MainView/MessageElement/ClipElement.vue
  • src/components/Main/MainView/MessageElement/MessageContents.vue
  • src/components/Main/MainView/MessageElement/MessageElement.vue
  • src/components/Main/MainView/MessageElement/composables/useElementRenderObserver.ts
  • src/components/Main/MainView/MessagesScroller/MessagesScroller.vue
  • src/components/Main/MainView/MessagesScroller/MessagesSkeleton.vue
  • src/components/Main/MainView/MessagesScroller/composables/useMessageScroller.ts
  • src/components/Main/MainView/MessagesScroller/composables/useMessageScrollerElementResizeObserver.ts
  • src/components/Main/MainView/MessagesScroller/composables/useMessagesFetcher.ts
  • src/components/UI/DeferredRender.vue
  • src/components/UI/MarkdownContent.vue
  • src/components/UI/SlideDown.vue
  • src/composables/message/useEmbeddings.ts
  • src/lib/basic/timer.ts
  • src/styles/global.scss
  • tests/unit/lib/basic/timer.spec.ts
💤 Files with no reviewable changes (1)
  • src/components/Main/MainView/MessagesScroller/composables/useMessageScrollerElementResizeObserver.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment