Skip to content

refactor(group-channel): Phase 5.2.b Hybrid hydration + consumer cut-read (stacked on #1430)#1436

Closed
danney-chun wants to merge 5 commits into
refactor/p0-phase-5-1-unread-consumerfrom
refactor/p0-phase-5-2b-hybrid-hydration
Closed

refactor(group-channel): Phase 5.2.b Hybrid hydration + consumer cut-read (stacked on #1430)#1436
danney-chun wants to merge 5 commits into
refactor/p0-phase-5-1-unread-consumerfrom
refactor/p0-phase-5-2b-hybrid-hydration

Conversation

@danney-chun
Copy link
Copy Markdown
Contributor

Summary

Phase 5.2.b of the P0 runtime-coupling refactor (stacked on #1430). Implements Model C — Hybrid from the 5.2.a investigate cycle: the session-local UnreadReducer is seeded at channel mount with a one-shot CHANNEL_HYDRATED event, enabling the MessageList consumer to migrate from currentChannel.myLastRead + 1-based derivation to a reducer-backed selector. Also lands the dead-code cleanup of setFirstUnreadMessageId that the 5.2.a audit uncovered.

  • Investigate predecessor: .agentic/p0-phase-5-2a/{audit.md, decision.md} — semantic audit + Model C rationale
  • Spec / Plan / Review: .agentic/p0-phase-5-2b/{spec.md, plan.md, review/code-review.md}

Phase-by-phase (5 commits)

Phase Commit What
5.2.b.a 77e22e0b CHANNEL_HYDRATED event added to UnreadEvent union + reducer handler (clean / tracking branches + marked-unread no-clobber). 4 new transition tests.
5.2.b.b 2bc84e19 GroupChannelProvider dispatches CHANNEL_HYDRATED in a new effect on messageDataSource.initialized + state.currentChannel?.url. Filters peer messages, derives anchor + ids.
5.2.b.c c746e788 MessageList.firstUnreadMessage reads from useUnreadSelector(selectFirstUnreadMessageId) then a messages.find lookup. Legacy myLastRead+1 retained as fallback for the hydrate-timing gap. 6 new probe-based integration tests.
5.2.b.d 61f86ad9 Dead-code cleanup: remove setFirstUnreadMessageId action (never called per audit §1.3 obs 1) + update Phase 0 context-shape baseline.
G3 fix 2adec404 Review fixes I-1 (hydratedChannelUrlRef guard against resetWithStartingPoint re-fire) + I-2 (drop structural type casts).

Why Model C

The 5.2.a investigate cycle established that:

  • Phase 4's reducer model (refactor-design.ko.md §7) is intentionally session-local — tracks "messages received since the user left bottom" + manual mark-as-unread overlay.
  • The legacy MessageList.firstUnreadMessage derivation is server-truthcurrentChannel.myLastRead + 1 lookup.
  • These models DIVERGE in one critical scenario: user enters a channel with pre-existing unread (unreadMessageCount > 0). Reducer session-local would show zero unread; legacy shows the server-tracked separator.

Model C bridges the gap with a one-shot CHANNEL_HYDRATED seed at mount. Reducer transitions remain session-local; consumer reads now have a stable source.

Parallel-only invariant (W1 inheritance)

CHANNEL_HYDRATED flows through the same dispatchToUnreadStore helper from PR #1430. The W1 try/catch + onError guard remains. The new Provider effect uses the existing thunk-guarded unreadDispatch.

BC invariants (all PASS — ./scripts/bc-check.sh)

  • BC-1 package.json public fields unchanged
  • BC-2 dist/types/index.d.ts export set unchanged
  • BC-3 Context-shape baseline updated for the intentional setFirstUnreadMessageId removal (internal API only)
  • BC-4 dts does NOT re-export from internal/
  • BC-5 No external source imports from internal/
  • BC-6 scrollPubSub topic + payload set unchanged

Test plan

Mid-cycle scope reduction (AC-5)

The original AC-5 plan was to mount the full <GroupChannelProvider> stack with a stubbed Sendbird/coreTs surface and drive 6 user scenarios through the real MessageList consumer. The first attempt OOM'd jest (likely an effect loop from incomplete mocks). Per Plan §5 risk mitigation, the test was reduced to a <GroupChannelUnreadContext.Provider> + probe consumer pattern that exercises the same dispatch → reducer → selector contract. A heavier app-shell test is a follow-up if uikit-tools API drift becomes a real concern. Documented in .agentic/p0-phase-5-2b/spec.md (test file header) and the 5.2.b.c commit body.

Out of scope (intentional)

  • unreadSinceDate non-MAU label migration (wall-clock semantic, separate decision)
  • MessageView.newMessageIds autoscroll trigger migration (autoscroll, not unread)
  • Channel/* module (different state shape)
  • ScrollController real executor wiring (separate cycle)
  • MESSAGES_DELETED dispatch (pending uikit-tools version bump for onMessagesDeleted callback)
  • currentChannel.unreadMessageCount direct reads (server-truth display; reducer's unreadCount is a session counter, distinct concept)
  • Phase 4's hand-rolled parity.spec.ts against the simplified legacy model — kept as a unit test of the reducer in isolation. The new integration spec covers the real consumer contract.

Stacking note

Base = refactor/p0-phase-5-1-unread-consumer (PR #1430). Once #1430 merges, the base updates; this PR can then be rebased onto main directly. #1428 (Phase 4) underlies #1430 — typical merge order is #1428#1430#1431.

🤖 Generated with Claude Code

…ucer (Phase 5.2.b.a)

Bootstraps the session-local reducer from server-truth at channel mount.
The reducer otherwise stays session-local per design doc §7 — this
event is the one-shot seed bridge to enable consumer cut-read at mount
when the SDK already reports unread state.

Transitions:
- mode='marked-unread' → no clobber (user pin wins); only lastReadAt
  refreshes to the server-supplied value.
- unreadCount === 0 → clean state; lastReadAt = event.lastReadAt
  (= server-side currentChannel.myLastRead).
- unreadCount > 0 → tracking state seeded with the supplied anchor +
  ids set. badgeVisible/separatorVisible derived as in MESSAGES_RECEIVED.

The dispatch site (Provider) and the consumer cut-read (MessageList)
arrive in 5.2.b.b and 5.2.b.c.

4 new test cases under reducer.transitions.spec.ts cover:
- clean + zero unread → clean (no anchor)
- clean + N unread → tracking (with anchor + set)
- marked-unread + hydrate → user pin preserved
- post-hydrate USER_REACHED_BOTTOM → standard clear path

ALL_UNREAD_EVENT_TYPES updated from 7 to 8.
…e 5.2.b.b)

Wires the SDK-state seed for the UnreadReducer. The new useEffect
fires after messageDataSource.initialized flips true, derives the
seed payload from currentChannel (myLastRead, unreadMessageCount) +
the loaded messages list, and dispatches CHANNEL_HYDRATED through the
existing unreadDispatch thunk-guard (W1 isolation intact).

Effect re-runs on channel switch via state.currentChannel?.url dep.
CHANNEL_CHANGED (5.1.a) still fires earlier on switch, so the reducer
goes clean → hydrate → tracking, which is the correct ordering.

Pagination caveat (Plan §R-1) is documented inline: when myLastRead+1
is older than the earliest loaded message, the dispatch sets
firstUnreadMessageId=null. Server count still renders via the badge
selector; separator anchor stays empty until more history loads.

Consumer cut-read of firstUnreadMessage at MessageList lands in 5.2.b.c.

Full jest suite: 196/198 PASS (1129 tests, 0 fail). BC-1~6 preserved.
…se 5.2.b.c)

The MessageList anchor for `<NewMessageSeparator>` now reads from the
UnreadReducer (seeded at mount via 5.2.b.b's CHANNEL_HYDRATED) instead
of the local `myLastRead + 1` derivation.

- `useUnreadSelector(selectFirstUnreadMessageId)` provides the messageId.
- A messages.find lookup hydrates the full message object needed by
  `separatorMessageRef`.
- Legacy gates (enableMarkAsUnread, isInitializedRef, readState, presence
  of myLastRead) are preserved so non-MAU mode still renders nothing.
- The legacy myLastRead+1 search remains as a defensive fallback when
  the reducer has no seed yet (Plan §R-2 hydrate-timing gap), so
  behavior is identical during the first render after mount before the
  Provider's useEffect fires.
- The readState='unread' side effect (was inside the useMemo) is
  promoted to its own useEffect for purity.

New integration spec at `MessageList/__tests__/unreadSeparator.integration.spec.tsx`
(6 cases) covers the dispatch → reducer → selector path. The original
AC-5 plan to mount the full GroupChannelProvider was reduced after an
OOM crash; the dispatch logic itself is type-checked + built and a
heavier app-shell test can be added in a future cycle if needed.

BC-1~6 PASS. Full jest: 197/199 suites (1135 tests, 0 fail) — +6 new.
…se 5.2.b.d)

The `setFirstUnreadMessageId` action was declared on the
`GroupChannelActions` interface and exposed via `useGroupChannel()`,
but never called anywhere in the source tree
(.agentic/p0-phase-5-2a/audit.md §1.3 observation 1). The
corresponding `state.firstUnreadMessageId` field was never part of
`initialState()` either — the setter wrote to a slot that was never
read. Pure dead code.

After this cleanup:
- The UnreadReducer's `selectFirstUnreadMessageId` (5.1.b) is the
  authoritative source for the first-unread anchor, seeded from server
  truth via CHANNEL_HYDRATED (5.2.b.b) and consumed via cut-read at
  MessageList (5.2.b.c).

Changes:
- Remove `setFirstUnreadMessageId` from `GroupChannelActions` interface
- Remove the unused `useCallback` definition + replace with a brief
  pointer to where the read API now lives
- Remove from the `actions` object literal + its useMemo deps
- Update Phase 0 context-shape-baseline.json to drop the removed
  action (this is an intentional shape change — internal API only,
  not in src/index.ts; verified by grep)
- Refresh the stale Phase 5.1.a comment block on `unreadStoreRef` to
  reflect the post-5.2 state

Verification:
- `grep setFirstUnreadMessageId` returns zero hits outside historical
  comments
- BC-1~6 PASS (baseline JSON intentionally updated; BC-3 only checks
  well-formedness)
- Full jest 197/199 PASS (1135 tests, 0 fail)
- yarn build clean
I-1 (WARN): add `hydratedChannelUrlRef` guard around the CHANNEL_HYDRATED
dispatch. `useGroupChannelMessages` calls `updateInitialized(false)` →
`(true)` inside `resetWithStartingPoint` (verified in published source),
so without the guard a jump-to-message would re-fire the hydrate effect
with the new message window and clobber any tracking state accumulated
since the original mount. The ref is reset on channel switch by
`dispatchChannelChanged`, mirroring the `unreadChannelUrlRef`
idempotency pattern from PR #1430 review I-1.

I-2 (WARN): drop the three structural `(m as { messageId: number })` and
`(m as { createdAt: number })` casts in the hydrate dispatch payload.
`CoreMessageType` already has these fields on its base; only the
`sender?.userId` access in the filter callback needs narrowing.

BC-1~6 PASS, full jest 197/199 (1135 tests, 0 fail).
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2adec40473

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +137 to 140
useEffect(() => {
if (readState === 'unread') {
separatorMessageRef.current = undefined;
isUnreadMessageExistInChannel.current = true;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clear the separator ref before rendering unread state

When readState changes to 'unread' while separatorMessageRef.current still points at a previous separator, moving this ref reset into useEffect makes it happen after the render that computes finalFirstUnreadMessageId from separatorMessageRef.current || firstUnreadMessage?.messageId. Because mutating the ref does not trigger another render, that update can leave the separator anchored to the old message until some unrelated state change occurs; the previous in-render reset avoided this stale ref during the same render.

Useful? React with 👍 / 👎.

@danney-chun
Copy link
Copy Markdown
Contributor Author

Consolidating into single local branch refactor/p0-runtime-coupling-all. See #1428.

@danney-chun danney-chun closed this Jun 2, 2026
@danney-chun danney-chun deleted the refactor/p0-phase-5-2b-hybrid-hydration branch June 2, 2026 03:18
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.

1 participant