Skip to content

feat: bounded message window for centered Jump to Message#7388

Open
diegolmello wants to merge 32 commits into
developfrom
oasis-ocean
Open

feat: bounded message window for centered Jump to Message#7388
diegolmello wants to merge 32 commits into
developfrom
oasis-ocean

Conversation

@diegolmello

@diegolmello diegolmello commented Jun 8, 2026

Copy link
Copy Markdown
Member

Proposed changes

Rebuilds Jump to Message on a bounded message window so a deep jump re-anchors in one step (O(1)) instead of growing the window page-by-page (O(pages)) — removing the root cause of the old 5s-race flakiness — without migrating off the custom native inverted list.

The whole capability is one optional upper ts bound (highTs) on the existing WatermelonDB observation:

  • highTs == nullLive Window (newest-first, follows the Live Tail) — unchanged default behavior.
  • finite highTsAnchored Window pinned around the target, below the Live Tail.

A pure, DB/React-free anchor resolver decides the bound (anchorForTarget) and climbs it back toward live as Newer Loaders are consumed (raiseOrRelease), releasing to a Live Window only once the Gap to the Live Tail has fully closed (invariant: never release across an open Gap). "Rejoin live" is now explicit — the Load Newer chain or the jump-to-bottom FAB (which stays visible while anchored).

Also included:

  • Reading-position preservation on release — on RELEASE, grow the Live Window by the count of cached messages above the old bound so a deep target isn't evicted by take(count) and the list doesn't snap to the Live Tail.
  • Scroll-to-index recursion guard — defer one frame + cap retries for onScrollToIndexFailed (no getItemLayout → the inverted list re-fires it synchronously → stack overflow). The retry re-applies the same centering + header-clearing view offset as the initial scroll, so a distant jump that falls back to the retry path still lands centered instead of hidden behind the room header.
  • Precise landing on a fresh window — with no getItemLayout for these variable-height messages, the first scrollToIndex uses an estimated offset and can undershoot while the target's row is still unmeasured (e.g. entering a room fresh from the rooms list), leaving the target hidden above the viewport. The jump now scrolls a second time against the measured frame once the row has rendered, so the target always lands centered.
  • Bounded window growth for deep anchored targets — when a target sits below the anchored window's first page (a large Chunk or filtered rows between the bound and the target), the jump grows the window one page at a time (capped) to pull the target into the rendered rows, instead of waiting out the safety timeout and aborting.
  • Jump orchestration — gate anchoring on window membership (isMessageInWindow), not message origin, so a locally-cached-but-out-of-window target no longer silently aborts; three roads (in-window / cached-out-of-window / server). A server-fetched target whose chunk reaches the Live Tail (e.g. a push-notification deep link onto a recent message) keeps the room in a Live Window instead of pinning it in an unreleasable historical view; the target-ts fallback covers the remaining cases (cached target with no bracketing Loader, target absent from the fetched chunk).
  • Supporting fixes: keep the live socket on deeplink login; tolerate an empty 2FA code on the DDP loadSurroundingMessages call; one-shot jump param so re-searching the same message re-fires; guard the anchor raise/release against a re-subscribe landing mid-flight (room switch / concurrent raise) so a stale read can't corrupt the new window.

Architecture and flow diagrams are documented alongside the code:

  • app/views/RoomView/docs/ARCHITECTURE.md — the window model, layer responsibilities, anchor resolution, the rejoin (raise/release) climb, scroll landing, the rejected FlashList v2 migration and why, and the load-bearing invariants tied to the unit tests.
  • app/views/RoomView/docs/FLOWS.md — sequence diagrams for each jump and window-management handshake (in-window jump, server-fetched anchor, deep-target growth + safety net, frontier climb, raise/release rejoin, jump-to-bottom, thread-jump timing).

New domain vocabulary is in UBIQUITOUS_LANGUAGE.md (§ Message Loading).

Issue(s)

https://rocketchat.atlassian.net/browse/NATIVE-1224

How to test or reproduce

  1. Open a room with a long history (hundreds of messages, with gaps).
  2. Search for / tap a target far from the Live Tail. It should re-anchor and center on the target in one step — no long page-by-page scroll, no abort — and land clear of the room header (not tucked behind it), including when entering the room fresh from the rooms list (rows not yet measured) and for very deep targets that fall back to the scroll retry path.
  3. Climb back via "Load newer" / scrolling up; the window rejoins live only when the gap closes; the jump-to-bottom FAB is available throughout.
  4. Jump to a deep target, then keep loading newer until release — the reading position is preserved (no snap to the Live Tail).
  5. Jump to the same message twice in a row via Search — it re-fires both times.
  6. Jump to a quoted reply pointing at an on-screen message — it scrolls in place, leaving the Live Tail intact.
  7. Tap a push notification for a recent message in a room whose history isn't cached — the room opens at the Live Tail with the message visible and keeps rendering newly arriving messages (it must not open in a stuck historical view).

Screenshots

No static UI redesign; changes are behavioral (centered jump; FAB visibility while anchored).

Types of changes

  • Bugfix (non-breaking change which fixes an issue)
  • Improvement (non-breaking change which improves a current function)
  • New feature (non-breaking change which adds functionality)
  • Documentation update (if none of the other choices apply)

Checklist

  • I have read the CONTRIBUTING doc
  • I have signed the CLA
  • Lint and unit tests pass locally with my changes
  • I have added tests that prove my fix is effective or that my feature works (if applicable)
  • I have added necessary documentation (if applicable)
  • Any dependent changes have been merged and published in downstream modules

Further comments

The list engine is unchanged; a future FlashList migration remains possible but is neither required nor blocked by this work. Ordering stays ts-only (a ts + _id composite is a deferred follow-up noted in ARCHITECTURE.md). New unit coverage: anchorResolver (including the server-chunk resolver), useMessages (release-window growth), useScroll (deferred + capped retry, header-clearing offset on retry, re-scroll after measurement, bounded growth + growth cap for deep anchored targets), getLocalAnchor.

@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

This PR implements anchored, upper-bounded message windows (highTs) for Jump-to-Message: domain terminology and ADR, anchor resolution helpers, local anchor service, message-query bounding and rejoin logic in useMessages, a pending-jump scroll state machine in useScroll, list wiring and UI adjustments for anchored views, and RoomView orchestration to compute and use anchors.

Changes

Bounded Message Window for Jump-to-Message

Layer / File(s) Summary
Domain terminology and anchor resolution contracts
UBIQUITOUS_LANGUAGE.md, app/views/RoomView/List/hooks/anchorResolver.ts, app/views/RoomView/List/hooks/anchorResolver.test.ts, app/views/RoomView/docs/adr/0001-bounded-window-over-flashlist.md
Adds Message Loading terminology (Message Window, Live Tail, Anchored Window, Chunks, Loader Rows, Jump), clarifies ambiguous terms, and implements anchorForTarget and raiseOrRelease with tests and ADR documenting the bounded-window approach.
Message info extensions and local anchor service
app/views/RoomView/services/getMessageInfo.ts, app/views/RoomView/services/getLocalAnchor.ts, app/views/RoomView/services/getLocalAnchor.test.ts, app/views/RoomView/services/index.ts, app/lib/methods/helpers/tsToMs.ts, app/lib/methods/helpers/index.ts
Adds ts to message payloads, introduces tsToMs helper and re-export, implements getLocalAnchorTs to find nearest newer-loader above a target from local DB, tests its behavior, and exports the service.
List interfaces and anchored navigation
app/views/RoomView/List/definitions.ts, app/views/RoomView/List/components/List.tsx, app/views/RoomView/List/constants.ts
Adds isAnchored?: boolean to IListProps, extends IListContainerRef.jumpToMessage to accept optional highTs, adds isMessageInWindow, adjusts FAB visibility logic, and introduces pagination/scrolling constants.
Message observation with anchored bounds and rejoin transitions
app/views/RoomView/List/hooks/useMessages.ts, app/views/RoomView/List/hooks/useMessages.test.tsx
Implements optional ts <= highTs query bounds for anchored windows, tracks boundary-loader presence, detects present→absent transitions and performs targeted one-shot reads to decide raise-or-release (or increase take on release), resets pagination on anchor changes, and exposes { highTs, setHighTs } with tests validating bound clauses and transitions.
Jump state machine and scroll retry orchestration
app/views/RoomView/List/hooks/useScroll.ts, app/views/RoomView/List/hooks/useScroll.test.tsx
Refactors useScroll into a single in-flight pendingJump model with safety timeouts, useLayoutEffect-driven completion when target appears, bounded anchored growth via fetchMessages, deferred bounded retries for scrollToIndex failures that recompute target index and preserve offsets, anchor-release on jumpToBottom, and extensive tests covering anchored/non-anchored flows, retries, and safety aborts.
List component and container wiring for anchored mode
app/views/RoomView/List/index.tsx, app/views/RoomView/List/components/List.tsx
Threads highTs/setHighTs into useScroll, exposes isMessageInWindow on the list ref, passes isAnchored={highTs != null} to List, and keeps FAB visible while anchored.
RoomView jump orchestration with anchoring
app/views/RoomView/index.tsx
Coordinates Jump-to-Message: checks in-window membership, computes highTs using anchorForTarget (server) or getLocalAnchorTs (local), falls back to message ts if needed, synchronizes Fabric then calls ListContainer.jumpToMessage(message.id, highTs), and clears one-shot jumpToMessageId navigation params after handling.

SDK TOTP Argument Handling

Layer / File(s) Summary
Conditional TOTP argument in SDK method calls
app/lib/services/sdk.ts
Updates Sdk.methodCall to append the TOTP positional argument only when present using a conditional spread, avoiding unconditional empty-string trailing args that can break method signatures.

Sequence Diagram(s)

sequenceDiagram
  participant RoomView
  participant RoomServices_getLocalAnchorTs
  participant loadSurroundingMessages
  participant anchorForTarget
  participant ListContainer
  participant useScroll
  participant useMessages
  participant WatermelonDB
  RoomView->>RoomServices_getLocalAnchorTs: request local anchor (rid, targetTs)
  alt local anchor found
    RoomServices_getLocalAnchorTs-->>RoomView: returns highTs
  else fallback
    RoomView->>loadSurroundingMessages: fetch surrounding messages
    loadSurroundingMessages->>anchorForTarget: compute highTs from rows
    anchorForTarget-->>RoomView: returns highTs or null
  end
  RoomView->>ListContainer: jumpToMessage(messageId, highTs)
  ListContainer->>useScroll: jumpToMessage(messageId, highTs)
  useScroll->>useMessages: setHighTs(highTs) / fetchMessages()
  useMessages->>WatermelonDB: observe / one-shot fetch (ts <= highTs)
  WatermelonDB-->>useMessages: rows include target
  useMessages-->>useScroll: messages updated -> complete jump (scroll & highlight)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Suggested reviewers

  • OtavioStasiak
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The pull request title 'feat: bounded message window for centered Jump to Message' clearly and directly summarizes the main change: implementing a bounded message window feature for the Jump to Message functionality.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

Warning

Review ran into problems

🔥 Problems

Errors were encountered while retrieving linked issues.

Errors (1)
  • NATIVE-1224: Request failed with status code 401

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@diegolmello diegolmello changed the title feat(RoomView): bounded message window for centered Jump to Message feat: bounded message window for centered Jump to Message Jun 8, 2026
@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown

iOS Build Available

Rocket.Chat 4.74.0.109044

@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown

iOS Build Available

Rocket.Chat 4.74.0.109049

@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

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

⚠️ Outside diff range comments (3)
app/views/RoomView/List/hooks/useScroll.test.tsx (1)

19-38: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Mock fetchMessages with the same async contract as production.

useScroll expects fetchMessages: () => Promise<void>, but this helper injects a plain jest.fn() that returns undefined. That means this suite won't catch promise-handling regressions in the new growth path. Make the default mock resolve a promise so the tests exercise the real contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/views/RoomView/List/hooks/useScroll.test.tsx` around lines 19 - 38, The
test helper renderUseScroll currently injects fetchMessages as jest.fn() which
returns undefined; update the default mock to match the production contract (a
function returning a Promise<void>) so tests exercise async behavior—e.g. change
the default fetchMessages parameter in renderUseScroll to an async-resolving
mock (use fetchMessages = jest.fn().mockResolvedValue(undefined) or an async ()
=> Promise.resolve()) and ensure useScroll is invoked with that mock; keep
references to renderUseScroll, useScroll, makeListRef and makeMessagesIdsRef so
the helper remains otherwise unchanged.
app/views/RoomView/List/hooks/useScroll.ts (2)

72-80: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Resolve the in-flight jump during unmount cleanup.

This cleanup clears pendingJump.current?.safety, but it never settles the promise returned by jumpToMessage(). If the list unmounts mid-jump (for example during a room switch), the only remaining resolution path is removed here and any caller awaiting that jump stays pending indefinitely.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/views/RoomView/List/hooks/useScroll.ts` around lines 72 - 80, The cleanup
in useEffect currently clears highlightTimeout.current and
pendingJump.current.safety but doesn't settle the promise created by
jumpToMessage(), leaving callers awaiting indefinitely; update the unmount
cleanup in useScroll.ts to, after clearing pendingJump.current.safety, also call
the stored completion callback on pendingJump.current (e.g.,
pendingJump.current.reject or pendingJump.current.resolve) with a
cancellation/error value so the jumpToMessage promise is settled, then null out
pendingJump.current to avoid leaks; reference pendingJump, jumpToMessage,
highlightTimeout and the useEffect cleanup when making the change.

176-182: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Bind deferred scroll retries to the jump that scheduled them.

This timeout keeps params.highestMeasuredFrameIndex from the old failure, but it re-reads the latest target id at fire time. If another jump starts before the timer runs and its target is still absent, targetIndex stays -1 and this falls back to the previous jump's measured index, which can scroll the new window to the wrong row. Track/cancel the retry timer per jump before issuing the fallback scroll.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/views/RoomView/List/hooks/useScroll.ts` around lines 176 - 182, The
deferred scroll retry must be bound to the specific jump that scheduled it: when
scheduling the setTimeout inside useScroll, capture the current jump identifier
(e.g., pendingJump.current?.messageId or an internal jumpId) and store the timer
id on that jump (e.g., pendingJump.current.retryTimer or a map keyed by jumpId);
when a new jump starts clear any existing retry timer for the previous jump;
inside the timeout callback verify the captured jumpId still matches the active
jump (e.g., pendingJump.current?.messageId === capturedJumpId) before falling
back to params.highestMeasuredFrameIndex and calling
listRef.current.scrollToIndex, and clear the stored retryTimer on success or
cancellation so retries don’t apply to later jumps.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/views/RoomView/List/hooks/useScroll.ts`:
- Around line 149-154: The code calls fetchMessages() fire-and-forget inside the
anchored-jump growth branch which can produce unhandled promise rejections; wrap
the fetchMessages() call with explicit error handling (e.g.
fetchMessages().catch(err => { /* surface error and abort the jump */ })) and on
error either abort the jump (use the existing jump state handler or call an
abort function) or set a visible failure state so the safety timeout doesn't
silently wait; ensure you reference and update jumpGrowthRetries.current and
MAX_JUMP_GROWTH_RETRIES logic consistently when handling the error so
retries/aborts remain correct.

---

Outside diff comments:
In `@app/views/RoomView/List/hooks/useScroll.test.tsx`:
- Around line 19-38: The test helper renderUseScroll currently injects
fetchMessages as jest.fn() which returns undefined; update the default mock to
match the production contract (a function returning a Promise<void>) so tests
exercise async behavior—e.g. change the default fetchMessages parameter in
renderUseScroll to an async-resolving mock (use fetchMessages =
jest.fn().mockResolvedValue(undefined) or an async () => Promise.resolve()) and
ensure useScroll is invoked with that mock; keep references to renderUseScroll,
useScroll, makeListRef and makeMessagesIdsRef so the helper remains otherwise
unchanged.

In `@app/views/RoomView/List/hooks/useScroll.ts`:
- Around line 72-80: The cleanup in useEffect currently clears
highlightTimeout.current and pendingJump.current.safety but doesn't settle the
promise created by jumpToMessage(), leaving callers awaiting indefinitely;
update the unmount cleanup in useScroll.ts to, after clearing
pendingJump.current.safety, also call the stored completion callback on
pendingJump.current (e.g., pendingJump.current.reject or
pendingJump.current.resolve) with a cancellation/error value so the
jumpToMessage promise is settled, then null out pendingJump.current to avoid
leaks; reference pendingJump, jumpToMessage, highlightTimeout and the useEffect
cleanup when making the change.
- Around line 176-182: The deferred scroll retry must be bound to the specific
jump that scheduled it: when scheduling the setTimeout inside useScroll, capture
the current jump identifier (e.g., pendingJump.current?.messageId or an internal
jumpId) and store the timer id on that jump (e.g.,
pendingJump.current.retryTimer or a map keyed by jumpId); when a new jump starts
clear any existing retry timer for the previous jump; inside the timeout
callback verify the captured jumpId still matches the active jump (e.g.,
pendingJump.current?.messageId === capturedJumpId) before falling back to
params.highestMeasuredFrameIndex and calling listRef.current.scrollToIndex, and
clear the stored retryTimer on success or cancellation so retries don’t apply to
later jumps.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 436f179d-7006-45ba-b668-0297ccf794b2

📥 Commits

Reviewing files that changed from the base of the PR and between 090bc41 and ad23b56.

📒 Files selected for processing (4)
  • app/views/RoomView/List/hooks/useMessages.ts
  • app/views/RoomView/List/hooks/useScroll.test.tsx
  • app/views/RoomView/List/hooks/useScroll.ts
  • app/views/RoomView/List/index.tsx
🚧 Files skipped from review as they are similar to previous changes (2)
  • app/views/RoomView/List/index.tsx
  • app/views/RoomView/List/hooks/useMessages.ts
📜 Review details
🧰 Additional context used
📓 Path-based instructions (5)
**/*.{js,ts,jsx,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{js,ts,jsx,tsx}: Use descriptive names for functions, variables, and classes that clearly convey their purpose
Write comments that explain the 'why' behind code decisions, not the 'what'
Keep functions small and focused on a single responsibility
Use const by default, let when reassignment is needed, and avoid var
Prefer async/await over .then() chains for handling asynchronous operations
Use explicit error handling with try/catch blocks for async operations
Avoid deeply nested code; refactor complex logic into helper functions

Files:

  • app/views/RoomView/List/hooks/useScroll.ts
  • app/views/RoomView/List/hooks/useScroll.test.tsx
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx}: Use TypeScript for type safety; add explicit type annotations to function parameters and return types
Prefer interfaces over type aliases for defining object shapes in TypeScript
Use enums for sets of related constants rather than magic strings or numbers

Use TypeScript with strict mode enabled

Files:

  • app/views/RoomView/List/hooks/useScroll.ts
  • app/views/RoomView/List/hooks/useScroll.test.tsx
**/*.{js,jsx,ts,tsx,json}

📄 CodeRabbit inference engine (CLAUDE.md)

Use Prettier formatting with tabs, single quotes, 130 character line width, no trailing commas, and avoid arrow function parentheses

Files:

  • app/views/RoomView/List/hooks/useScroll.ts
  • app/views/RoomView/List/hooks/useScroll.test.tsx
**/*.{js,jsx,ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

Enforce ESLint rules from @rocket.chat/eslint-config with React, React Native, TypeScript, and Jest plugins

Files:

  • app/views/RoomView/List/hooks/useScroll.ts
  • app/views/RoomView/List/hooks/useScroll.test.tsx
app/views/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

Place screen components in 'app/views/' directory

Files:

  • app/views/RoomView/List/hooks/useScroll.ts
  • app/views/RoomView/List/hooks/useScroll.test.tsx
🧠 Learnings (1)
📚 Learning: 2026-04-30T17:07:51.020Z
Learnt from: diegolmello
Repo: RocketChat/Rocket.Chat.ReactNative PR: 7274
File: app/lib/services/voip/MediaCallEvents.ts:0-0
Timestamp: 2026-04-30T17:07:51.020Z
Learning: In this Rocket.Chat React Native codebase, the ESLint rule `no-void: error` is enforced. When you see a promise returned from an async call that is not awaited (a “floating promise”), do not silence it with the `void somePromise()` pattern. Instead, handle the promise explicitly by attaching `.catch(...)` (or otherwise awaiting/handling the error) so unhandled-rejection risks are addressed in a way that satisfies the existing ESLint configuration.

Applied to files:

  • app/views/RoomView/List/hooks/useScroll.ts
  • app/views/RoomView/List/hooks/useScroll.test.tsx

Comment thread app/views/RoomView/List/hooks/useScroll.ts Outdated
@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown

iOS Build Available

Rocket.Chat 4.74.0.109053

@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown

@github-actions

Copy link
Copy Markdown

@github-actions

Copy link
Copy Markdown

iOS Build Available

Rocket.Chat 4.74.0.109126

@diegolmello diegolmello left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Missing a doc with architecture and flow

Comment thread app/lib/methods/helpers/tsToMs.ts Outdated
Comment thread app/lib/methods/helpers/index.ts Outdated
Comment thread app/lib/services/sdk.ts Outdated
Comment thread app/views/RoomView/docs/adr/0001-bounded-window-over-flashlist.md Outdated
Comment thread app/views/RoomView/List/components/List.tsx Outdated
Comment thread app/views/RoomView/List/components/List.tsx Outdated
Add ARCHITECTURE.md and FLOWS.md covering the anchored Message Window
model, jump lifecycle, raise/release climb, and the FlashList decision.

Move tsToMs next to dayjs, drop the helper barrel export, revert the
sdk 2FA-clear comment, and trim the List window/FAB comments.
Comment thread app/views/RoomView/List/hooks/useMessages.ts Outdated
Comment thread app/views/RoomView/List/hooks/useMessages.ts Outdated
Comment thread app/views/RoomView/List/hooks/useMessages.ts Outdated
Comment thread app/views/RoomView/List/hooks/useMessages.ts Outdated
Comment thread app/views/RoomView/List/hooks/useScroll.ts
Comment thread app/views/RoomView/List/definitions.ts
Comment thread app/views/RoomView/index.tsx Outdated
Trim verbose comments across the List jump path, drop dangling ADR
references, and add an explicit .catch on the bounded window-growth
fetchMessages call so a grow failure surfaces via the safety net rather
than as an unhandled rejection.
…ariants

Drop JSDoc scaffolding from anchorResolver (signature is the doc; concepts live in docs/ARCHITECTURE.md) and collapse the __DEV__ equal-ts collision warning to a single line.
@github-actions

Copy link
Copy Markdown

iOS Build Available

Rocket.Chat 4.74.0.109130

@github-actions

Copy link
Copy Markdown

Tapping "go to last message" from an anchored window swapped the tall
anchored rows for the shorter, disjoint live tail in a single emit. A
scroll offset deep enough for the tall content then sat past the short
content, and the inverted list rendered that as a blank wedge with the
FAB stuck visible — unrecoverable by swiping.

Pin the viewport to offset 0 (newest) on the still-settled anchored
content before releasing the bound: offset 0 is valid for any non-empty
window, so the shorter tail can't strand it. Suppress
maintainVisibleContentPosition across the swap (new isReleasing flag) so
its offset adjustment for the disjoint key set can't drag the viewport
back off-content, then re-pin once the live tail emits.

Claude-Session: https://claude.ai/code/session_01M5tYizcndsPdF3cQ5eLR3q
@github-actions

Copy link
Copy Markdown

iOS Build Available

Rocket.Chat 4.74.0.109151

@github-actions

Copy link
Copy Markdown

@diegolmello diegolmello left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Review of the bounded Message Window + anchored Jump-to-Message work — 15 inline notes below, grouped by kind in the order they appear.

  • Correctness — the load-bearing one is the rejoin read in useMessages that can truncate a still-present boundary loader and release the Anchored Window across an open Gap. The rest are lower severity: the auto-dispatch effect consuming the boundary loader while anchored, the jump completing before the target is confirmed on-screen, a count bump before the rid guard, and two latent guards.
  • TestscancelJumpToMessage / highlightedMessageId have no coverage; the getLocalAnchor mock can't catch query-shape regressions.
  • Docs/domain — a setHighTs step that doesn't exist and a boolean param label in the jump sequence diagram, a "no DB" self-contradiction in the layers table, and a rotting internal SHA on every diagram.
  • Slop/comments — a | any that defeats the jump's types, two over-long JSDocs, "clear the spinner" comments in a hook that owns no spinner, and a duplicated re-scroll block.

Two items aren't inline-able (their lines aren't in the diff): the loader-finder expression visibleMessages.find(m => m.t && MESSAGE_TYPE_ANY_LOAD.includes(...))?.id is duplicated verbatim in useMessages (the rid-change snapshot effect and the auto-dispatch effect) — worth one shared helper; and the glossary drifts in two spots ("page" in useScroll, "(historical)" for the Anchored Window in List/definitions.ts) versus the terms in UBIQUITOUS_LANGUAGE.md.

// cannot see (the newly-written batch + any new Newer Loader).
const rows = (await database.active
.get('messages')
.query(Q.where('rid', rid), Q.where('ts', Q.gt(currentHighTs)), Q.sortBy('ts', Q.asc), Q.take(QUERY_SIZE + 1))

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Bug · high — spurious release across an open Gap. This rejoin read is Q.where('ts', Q.gt(highTs)), Q.sortBy('ts', asc), Q.take(QUERY_SIZE + 1) = 51 rows, with no hideSystemMessages filter (unlike the observation, which applies buildVisibleSystemTypesClause). loadNextMessages writes exactly QUERY_SIZE (50) messages plus one NEXT_CHUNK loader just above them, so 50 messages + loader already fill all 51 slots. Any pre-existing or system row in the (highTs, newLoaderTs] range pushes the boundary loader past slot 51; raiseOrRelease then receives loader-free rows, returns null, and releases the Anchored Window while the Gap is still open — the "no release across a Gap" invariant from ARCHITECTURE.md is violated, and count then grows by every row above the old bound.

The read shouldn't cap a slice of all rows — read the Newer Loaders above the bound directly and hand those to raiseOrRelease: Q.where('rid', rid), Q.where('t', NEXT_CHUNK), Q.where('ts', Q.gt(highTs)), Q.sortBy('ts', Q.desc), Q.take(1). Bounded, the loader can't be crowded out, and it mirrors getLocalAnchorTs. Also lets the separate fetchCount go.

@@ -173,5 +270,5 @@ export const useMessages = ({
}
}, [serverVersion, rid, t, hideSystemMessages, visibleMessages, dispatch, store]);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Bug · medium — auto-dispatch consumes the boundary loader while anchored. This effect (lines 251–271) has no highTs guard. In an Anchored Window the boundary Newer Loader sits at ts === highTs (it's inside the Q.lte(highTs) clause, so it's the first loader in visibleMessages). NEXT_CHUNK is in MESSAGE_TYPE_ANY_LOAD, so on a server ≥3.16.0 with hideSystemMessages set, the effect dispatches roomHistoryRequest for that boundary loader → loadNextMessages consumes it → the window auto-climbs (and can auto-release) with no user scroll, burning autoLoadCount toward MAX_AUTO_LOADS. That contradicts the "Anchored Window deliberately does not follow" contract — a user who jumped to an old message would see it drift toward live on its own.

Guard the effect on the bound (if (highTs != null) return;, with highTs added to the deps). The rejoin climb is the user-driven path via Load Newer, not this auto-loader.

}
jump.scrolled = true;
scrollToTarget(jump.messageId, index);
completeJump(jump);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Bug · low — jump completes without confirming the target is on-screen. completeJump (highlight + resolve) runs immediately after scrollToTarget kicks off the two-pass scroll, regardless of whether the row actually landed in the viewport. The two-pass scroll and the frontier climb cover an unmeasured frame, but a post-measurement undershoot on a very tall target row resolves the jump with the message sitting just above the fold. Narrow trigger — flagging because the jump now reports success without the viewability check the prior flow waited on.


const fetchMessages = useCallback(async () => {
unsubscribe();
count.current += QUERY_SIZE;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Bug · low — count bumped before the rid guard. count.current += QUERY_SIZE runs before if (!rid) return, so a render with a falsy rid still inflates count; the next real fetch then issues take(100) instead of take(QUERY_SIZE). Move the increment below the guard. (If rid is in fact always truthy here, the guard is dead code — either way the increment belongs after it.)

if (localAnchor != null) {
return localAnchor;
}
return target.ts ? tsToMs(target.ts) : null;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Bug · low (latent) — guard tests the raw value, not the computed ms. target.ts ? tsToMs(target.ts) : null falls to null for a numeric 0 or empty-string ts (no re-seed), whereas the server path anchors unconditionally. Unreachable today (a cached ts is a Date), but the intent is "anchor when ts is valid": const ms = tsToMs(target.ts); return Number.isFinite(ms) ? ms : null;.

Scroll->>Scroll: completeJump — clear safety, highlight
```

_Last verified: f78d6a37c_

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Docs · low — rotting marker. _Last verified: f78d6a37c_ (repeated on every diagram — lines 29, 68, 100, 125, 159, 183, 209) pins a bare internal commit SHA that isn't resolvable from the doc, isn't updated on merge, and goes stale the next time anything around it changes. Drop it; git history already records when each diagram last moved.

@@ -10,7 +10,9 @@ const getMessageInfo = async (messageId: string): Promise<TMessageModel | TThrea
id: message.id,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Slop · moderate — | any defeats the return type. The signature on line 6 is Promise<TMessageModel | TThreadMessageModel | any | null>; the | any collapses the whole union to any, so callers get no checking on the ts / shape the jump flow now depends on (resolveJumpAnchor reads target.ts / fromServer). All three returned literals here match the IJumpTarget shape already declared in resolveJumpAnchor.ts. Type the function Promise<IJumpTarget | null> and drop | any.

import { tsToMs } from '../../../lib/dayjs';
import { type TAnyMessageModel } from '../../../definitions';

/**

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Slop · moderate — over-long doc comment. This 12-line JSDoc (and the 11-line one on resolveJumpAnchor) re-narrates what the code and ARCHITECTURE.md's Anchor-resolution section already state. The part that earns its place: "returns the ts of the nearest Newer Loader above the target — the upper bracket of its Chunk — or null when the cached region is contiguous to live." ~3 lines is enough.


export const useScroll = ({ listRef, messagesIds }: { listRef: TListRef; messagesIds: TMessagesIdsRef }) => {
// Abort a jump whose target never re-observes within this window: release the anchor, drop to the Live
// Tail, clear the spinner. Does not cancel an in-flight scroll — completion is reactive on re-observe.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Slop · moderate — comment describes a side effect this hook doesn't own. useScroll has no spinner — the loading overlay is RoomView's sendLoadingEvent. "clear the spinner" here (and on lines 107 and 122) describes work this hook doesn't do; it resolves the jump promise and RoomView clears the overlay. Say "resolve the jump" / "release the anchor". Separately, the JUMP_SCROLL_POSITION comment (line 22) restates the literal values ("centered, offset clear, non-animated" = viewPosition: 0.5, viewOffset: 100, animated: false) — keep only the non-obvious animated: false rationale.

// highestMeasuredFrameIndex), then re-attempt — it lands once measured, or re-fires to climb on.
if (targetIndex > params.highestMeasuredFrameIndex) {
listRef.current?.scrollToIndex({ index: params.highestMeasuredFrameIndex, animated: false });
setTimeout(() => {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Slop · nit — duplicated re-scroll. This deferred block duplicates scrollToTarget's second pass (lines 159–168): same setTimeout(SCROLL_TO_INDEX_RETRY_DELAY), same findIndex settle, same scrollToIndex({ ...JUMP_SCROLL_POSITION }). Extract a shared reScrollWhenSettled(targetId) so the two can't drift apart.

…from consuming the boundary loader

Read the rejoin signal from the Newer Loader directly (t=NEXT_CHUNK, ts>bound,
desc, take 1) instead of a fixed-size, system-unfiltered row slab. A pre-existing
or system row in the gap could push the boundary loader out of the fetched set,
leaving the resolver loader-free and releasing the Anchored Window while the Gap
was still open.

Skip auto-dispatch while anchored (highTs guard + dependency): the boundary Newer
Loader sits at ts === highTs, inside the observed range, so on servers >= 3.16.0
with hideSystemMessages set the effect would dispatch a history request and climb
or release the window with no user scroll.

Extract reScrollWhenSettled, shared by the two-pass scroll and the
scroll-to-index-failed retry. Trim over-long JSDoc on getLocalAnchorTs and
resolveJumpAnchor. Drop "spinner" wording from useScroll comments (the hook owns
no spinner). Align FLOWS.md with the single jumpToMessage(id, highTs) entry point
and the number | null signature, and fix glossary drift in window-growth comments.

Add tests: cancelJumpToMessage branches and the highlightedMessageId lifecycle in
useScroll; the exact query clause shape in getLocalAnchor.

Claude-Session: https://claude.ai/code/session_01YQ7pAA9Tp5PQXC1j5b6Agx

@diegolmello diegolmello left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Follow-up addressing the review on this PR. Scope: moderate-or-higher findings plus all slop; low/nit items that aren't slop are deferred to a separate pass. Inline notes mark each change.

Addressed: the spurious release across an open Gap and the anchored auto-dispatch (correctness); both test gaps; the FLOWS.md call-graph/signature drift; the over-long JSDoc, the 'spinner' comments, and the duplicated re-scroll (slop); plus the duplicated loader-finder and the 'page'/'(historical)' glossary drift.

Not taken — | any on getMessageInfo's return: the suggested Promise<IJumpTarget | null> doesn't hold. The | any predates this PR, and the caller reads .rid / .replies / .tmsg / .t / .e2e / .tlm off the result and feeds it into model-typed navigation helpers; the returned literals carry fields beyond IJumpTarget. An honest fix cascades into those signatures — out of scope here, worth its own typing pass.

Deferred (low / latent, not slop): jump completing before confirming the target is on-screen; count bumped before the rid guard; the raw-value-vs-computed-ms guard in resolveJumpAnchor; the uncleared instance field on the main-list path; the ARCHITECTURE.md 'no DB' self-contradiction; the rotting verified-SHA marker.

Verified locally: RoomView suites 83/83, eslint clean on all touched files, tsc clean for these files (it also flags app/stacks/OutsideStack.tsx from the recent develop merge — unrelated to this change).

// invocation is stale and must not mutate count / highTs for the new window.
const sub = subscription.current;

// Read the Newer Loader closest to the Live Tail directly (highest ts above the bound) so non-loader rows can't crowd the boundary loader out of the fetched set.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Rejoin now reads the boundary Newer Loader directly — NEXT_CHUNK, ts > bound, desc, take(1) — instead of a fixed-size, system-unfiltered slab above the bound. With the old slab read, a pre-existing or system row in the gap could push the boundary loader past the fetched rows; the resolver then saw no loader and released the Anchored Window while the Gap was still open. The release-time count query stays — it feeds reading-position growth and isn't redundant with a single-loader read.

*/
useEffect(() => {
// Anchored Window deliberately does not auto-follow; only a Live Window auto-dispatches.
if (highTs != null) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

highTs != null guard (plus highTs in the deps) stops the Anchored Window auto-dispatching. The boundary Newer Loader sits at ts === highTs, inside the observed range; without this, servers >= 3.16.0 with hideSystemMessages set would consume it and climb or release the window with no user scroll.

import { roomHistoryRequest } from '../../../../actions/room';
import { isNewerLoader, raiseOrRelease, type AnchorMessage } from '../../services/anchorResolver';

const findFirstLoaderId = (messages: TAnyMessageModel[]): string | null =>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Extracted the duplicated loader-finder into findFirstLoaderId, shared by the rid-change snapshot effect and the auto-dispatch effect.


export const useScroll = ({ listRef, messagesIds }: { listRef: TListRef; messagesIds: TMessagesIdsRef }) => {
// Abort a jump whose target never re-observes within this window: release the anchor, drop to the Live
// Tail, resolve the jump. Does not cancel an in-flight scroll — completion is reactive on re-observe.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Dropped the 'clear the spinner' wording here and in completeJump / abortJump — this hook owns no spinner (RoomView's loading event does). Restated as resolving the jump.

// cap must cover the distance (5 stalled short). Bounded so an unreachable target aborts, not loops.
const MAX_SCROLL_TO_INDEX_RETRIES = 20;

// animated:false snaps straight to the target instead of smooth-scrolling through every row between here

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

JUMP_SCROLL_POSITION comment trimmed to the animated: false rationale; the prior text just restated the literal viewPosition / viewOffset values.

import { type TAnyMessageModel } from '../../../definitions';

/**
* ts of the nearest Newer Loader above the target = the upper bracket of its Chunk; null when the

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

JSDoc cut from 12 lines to 2; it re-narrated the code and ARCHITECTURE.md.

}

/**
* Upper ts bound (ms) for the re-seeded window, or null to stay on the Live Tail.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

JSDoc trimmed to the null cases plus the fromServer / cached split.

expect(query).toHaveBeenCalled();
});

it('builds the correct query clause shape', async () => {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

New test asserts the exact query clause shape (rid, NEXT_CHUNK, ts > target, asc, take(1)); dropping any clause now fails, where before only toHaveBeenCalled() was checked.

await waitFor(() => expect(jumpResolved).toBe(true));
});

it('cancelJumpToMessage is a safe no-op when no jump is pending', () => {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added coverage for both cancelJumpToMessage branches — no-op when nothing is pending, and abort + anchor release + safety-timer disarm when an unscrolled jump is pending — and for the highlightedMessageId lifecycle (set on completion, cleared after HIGHLIGHT_TIMEOUT).

Resolve->>AR: anchorForServerChunk(rows, id, ts)
AR-->>Resolve: highTs (Newer Loader ts) | null
Resolve-->>RV: bound
RV->>Scroll: jumpToMessage(id, highTs)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Diagram corrected: RV makes one call, jumpToMessage(id, highTs), and useScroll sets the bound internally — the prior RV->Msgs: setHighTs step didn't exist. Diagram 1 likewise shows the arg as null, not anchored=false (signature is number | null).

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant