Skip to content

refactor(group-channel): Phase 5.1 unread dispatch + read hook infra (stacked on #1428)#1430

Draft
danney-chun wants to merge 4 commits into
refactor/p0-phase-4-unread-reducerfrom
refactor/p0-phase-5-1-unread-consumer
Draft

refactor(group-channel): Phase 5.1 unread dispatch + read hook infra (stacked on #1428)#1430
danney-chun wants to merge 4 commits into
refactor/p0-phase-4-unread-reducerfrom
refactor/p0-phase-5-1-unread-consumer

Conversation

@danney-chun
Copy link
Copy Markdown
Contributor

Summary

Phase 5.1 of the P0 runtime-coupling refactor (stacked on #1428). Lands the dispatch-side and read-hook infrastructure for the UnreadReducer that Phase 4 (#1428) ships as parity-only. Consumer reads remain on legacy state — see "Mid-cycle scope reduction" below.

  • Spec / Plan / Review: .agentic/p0-phase-5-1/{spec.md, plan.md, review/code-review.md}
  • Cycle artifact: status.json, mid-cycle decisions captured

Phase-by-phase (4 commits)

Phase Commit What
5.1.a 8facc8bb internal/unread/integration.ts + Provider unreadDispatch thunk (6 events wired) + 8 W1 invariant tests
5.1.b 9ceeae3a GroupChannelUnreadContext + useUnreadSelector + selectIsMessageUnread + 5 unit tests
5.1.c 4c414cbb REDUCED scope — type narrow of getMessagePartsInfo.firstUnreadMessageId only
G3 fix 1fa01fa8 5 review improvements (I-1 idempotency / I-2 own-msg filter / I-3 typing / I-6 docs / I-7 JSDoc)

Mid-cycle scope reduction (important for reviewers)

Original 5.1.c planned to cut three consumer reads (MessageView.newMessageIds, MessageList.unreadSinceDate, MessageList.finalFirstUnreadMessageId) over to the new reducer-backed selectors. Mid-cycle audit revealed all three legacy fields encode semantics distinct from the Phase 4 reducer:

Site Legacy model Reducer model Conflict
newMessageIds gated by isAutoscrollMessageOverflowToTop; drives autoscroll unconditional MESSAGES_RECEIVED broader set → autoscroll fires more often
unreadSinceDate new Date() wall clock at leave-bottom firstUnreadCreatedAt = message epoch UI label semantics differ
finalFirstUnreadMessageId currentChannel.myLastRead + 1 (server truth) + manual ref session-local since-leave-bottom anchor server-truth vs UIKit-session

Phase 4's parity test compares the reducer against a simplified hand-rolled legacy model — it does NOT verify behavioral equivalence with real consumer code. Cutting any of the reads would be a behavior change, not a refactor.

This PR lands the dispatch + read-hook infrastructure with zero consumer impact, leaving the semantic decision to Phase 5.2.

What ships in this PR

  1. Dispatch wiring (GroupChannelProvider): unreadStoreRef + thunk-guarded unreadDispatch(makeEvent, isAtBottom?) + dispatchChannelChanged(url) (idempotent via unreadChannelUrlRef). 6 of 7 reducer events wired (MESSAGES_DELETED skipped — @sendbird/uikit-tools@0.1.0 lacks onMessagesDeleted). All dispatches are additive to legacy state writes (dual-write).
  2. Read API (module-private): GroupChannelUnreadContext + useUnreadSelector<T>(selector, equalityFn?). NOT re-exported from src/index.ts.
  3. Selector: selectIsMessageUnread(s, message) — primitive boolean return for identity stability.
  4. Type narrowing: getMessagePartsInfo.firstUnreadMessageId?: number | string | undefinednumber | null | undefined. All call sites already conform.

Parallel-only invariant (W1 inheritance)

dispatchToUnreadStore mirrors dispatchToRuntime: try/catch around reducer + applyStorePatch, onError callback, hook error swallow. unreadDispatch thunk additionally catches mapper throws. Verified by 3 cases in internal/unread/__tests__/integration.spec.ts (forced setState failure → swallow + onError + state preserved + no-onError safe).

BC invariants (all PASS)

  • BC-1 package.json public fields unchanged
  • BC-2 dist/types/index.d.ts export set unchanged
  • BC-3 Context shape baseline well-formed
  • 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

  • ./scripts/bc-check.sh — BC-1 ~ BC-6 all PASS
  • yarn jest — 196/198 PASS (2 pre-existing skipped, 0 fail), 1125 tests + 10 skipped — +13 new specs over refactor(group-channel): P0 runtime stability scaffolding (Phase 0-4) #1428 baseline
  • 5× deterministic re-run of P0 internal/unread + Phase 5.1 specs (5 suites / 50 tests) — stable
  • yarn build clean (pre-existing warnings only)
  • G3 code review (deep, js-agent) — Ship verdict, all 5 follow-up items applied

Phase 5.2 hand-off (must read before consumer migration)

The infrastructure landed here cannot drive consumer reads until the reducer semantics are reconciled with real consumer expectations. Phase 5.2 should:

  1. Audit the reducer's anchor semantics against the actual MessageList consumer pipeline (myLastRead-based vs session-local).
  2. Decide whether the reducer's session-local model is the desired consumer model (intentional product improvement) or whether the reducer should be redesigned to match server-truth.
  3. Replace Phase 4's parity test with one that compares against real consumer outputs, not a hand-rolled simplified legacy model.
  4. Address the fromCurrentUser semantic explicitly during cut-read (this PR already fixes the most painful version via filter-before-dispatch).

Out of scope (intentional)

  • Consumer cut-reads (deferred per mid-cycle decision)
  • MESSAGES_DELETED wiring (pending uikit-tools version bump)
  • ScrollController / RuntimeStore consumer migration (separate cycles)
  • Legacy state slice removal (newMessageIds, firstUnreadMessageId) — still authoritative

Stacking note

Base = refactor/p0-phase-4-unread-reducer (PR #1428). Once #1428 merges to main, this PR's base will be updated and rebased onto current main.

🤖 Generated with Claude Code

…patch (Phase 5.1.a)

Wires the UnreadReducer into GroupChannelProvider as the dispatch-side
half of the dual-write migration (spec §AC-1, §AC-8).

- `internal/unread/integration.ts` mirrors `runtime/integration.ts`:
  `createUnreadStore` + `dispatchToUnreadStore` with the W1 parallel-only
  guard (reducer/patch throws are caught + surfaced via onError; hook
  throws are swallowed). Adds the dev/test
  `__GROUP_CHANNEL_UNREAD_DISPATCH_HOOK__` instrumentation hook.
- `GroupChannelProvider` gains `unreadStoreRef` and a thunk-guarded
  `unreadDispatch` helper. 6 dispatch sites wired:
    * CHANNEL_CHANGED — useAsyncEffect (channel ready), onChannelUpdated
      (url change), onChannelDeleted, onCurrentUserBanned
    * MESSAGES_RECEIVED — onMessagesReceived (with conservative
      fromCurrentUser = all-messages-from-current-user)
    * USER_LEFT_BOTTOM / USER_REACHED_BOTTOM — new effect watching
      state.isScrollBottomReached
    * MARK_AS_UNREAD_SET — markAsUnread callback after SDK call
    * READ_CONFIRMED — onChannelUpdated EVENT_CHANNEL_READ branch
- MESSAGES_DELETED intentionally NOT wired — @sendbird/uikit-tools@0.1.0
  does not expose onMessagesDeleted (Plan §1). Reducer event stays
  callable for tests; Provider dispatch deferred.

Legacy writes (setNewMessageIds / setFirstUnreadMessageId) are
UNCHANGED — dual-write per spec §AC-4. Consumer read migration arrives
in 5.1.c.

8 new tests under `internal/unread/__tests__/integration.spec.ts`:
initial state, dispatch + state update, hook fire, hook error swallow,
subscriber notify, plus 3 W1 parallel-only cases (reducer/patch
exception, onError throw, no-onError).
… 5.1.b)

Adds the read-side surface for consumer migration (spec §AC-2).

- `context/GroupChannelUnreadContext.tsx` — React context holding the
  unread `Store<UnreadState> | null`. Module-private; NOT re-exported
  via `src/index.ts` (BC-2 verifies).
- `context/hooks/useUnreadSelector.ts` — thin wrapper over
  `useStoreSelector` with the new context. JSDoc warns consumers to use
  module-level or `useCallback`-stabilized selectors (W3 note inherited
  from Phase 1).
- `internal/unread/selectors.ts` — add `selectIsMessageUnread(s, message)`
  for the MessageView cut-read in 5.1.c. Returns a primitive boolean so
  identity is stable per `Object.is`.
- `GroupChannelProvider` — wrap `GroupChannelManager`'s children with
  `GroupChannelUnreadContext.Provider value={unreadStoreRef.current}`.

5 new tests under `context/hooks/__tests__/useUnreadSelector.spec.tsx`:
initial selector output, narrow-slice re-render boundary, throws
outside provider, selectIsMessageUnread membership tracking, custom
equality for derived shapes.

BC-1~6 PASS. No legacy code path is modified; consumer read migration
arrives in 5.1.c.
…eId prop type (Phase 5.1.c)

REDUCED scope after a mid-cycle semantic audit (see
.agentic/p0-phase-5-1/spec.md §AC-3 REDUCED).

The three originally planned consumer cut-read sites — `MessageView.newMessageIds`,
`MessageList.unreadSinceDate`, and `MessageList.finalFirstUnreadMessageId` —
each turned out to encode semantics distinct from the Phase 4 reducer:

- `newMessageIds` is gated by `isAutoscrollMessageOverflowToTop` and
  drives an autoscroll trigger; the reducer's set is broader.
- `unreadSinceDate` is a wall-clock `new Date()` captured when the user
  leaves the bottom; the reducer's `firstUnreadCreatedAt` is the
  message's own createdAt epoch.
- `finalFirstUnreadMessageId` is derived from `currentChannel.myLastRead + 1`
  (server-confirmed read state) + a manual `markAsUnread` ref; the
  reducer tracks UIKit-session-local "since-leave-bottom" anchors.

Phase 4's parity test compared the reducer against a simplified
hand-rolled legacy model, not the real consumer pipeline. Cutting any
of these reads would be a behavior change, not a refactor.

This commit narrows `firstUnreadMessageId?: number | string | undefined`
to `number | null | undefined` — the lone R-5 type cleanup that ships
with zero behavior impact. All internal call sites already conform.

Phase 5.2 hand-off: reducer semantics need re-auditing against
server-truth vs session-local before consumer reads can be migrated.
The 5.1.a (dispatch) + 5.1.b (read hook) infrastructure remains in
place for that future cycle.
Mid-cycle code review (.agentic/p0-phase-5-1/review/code-review.md) raised
no blockers but flagged five improvements; all five applied here.

- I-1 (WARN): `unreadChannelUrlRef` + `dispatchChannelChanged` helper.
  Collapses repeat CHANNEL_CHANGED transitions through a single guard.
  Reducer is already idempotent on this transition, but the guard
  prevents dev-hook double-fire when a stale onChannelUpdated closure
  races the mount-time useAsyncEffect.

- I-2 (WARN): Filter current-user messages BEFORE dispatch instead of
  passing `fromCurrentUser: every(...)`. The prior collapse rule
  mis-classified mixed bursts (one self + one peer) as `false`, growing
  the unread set with my own messageId. After filtering, the dispatched
  burst is by definition non-self → `fromCurrentUser: false` is correct
  and the set stays clean. No consumer reads from the set yet, but
  preempts a 5.2 footgun (the moment cut-read switches over, this would
  surface as self-flagged-unread).

- I-3 (WARN): Drop `(m: any)` casts on the dispatch boundary. Use the
  structurally-narrowed `sender?.userId` access; coreTs SendbirdMessage[]
  is the inferred type.

- I-6 (INFO): Add a cross-reference comment on the asymmetric error
  return (`dispatchToUnreadStore` returns prior state on failure;
  `dispatchToRuntime` returns empty effects array) so reviewers reading
  both side-by-side understand each is the unit appropriate to its
  contract.

- I-7 (INFO): Add `useUnreadSelector` JSDoc example demonstrating the
  `useCallback`-stabilized closure idiom — the W3-pattern caveat is
  real and well-tested but easy to trip over with prop-closing selectors.

BC-1~6 PASS, 196/198 suites (1125 tests, 0 fail) unchanged.
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: 1fa01fa8e4

ℹ️ 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".

selector: (state: UnreadState) => TSelected,
equalityFn?: EqualityFn<TSelected>,
): TSelected {
return useStoreSelector(GroupChannelUnreadContext, selector, equalityFn);
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 Recompute unread selectors when selector props change

This wrapper inherits useStoreSelector's cache, which returns the previous selected value whenever the raw store snapshot is the same, without considering that selector itself may have changed. For consumers that close over a changing prop, such as a message row calling useUnreadSelector(useCallback((s) => selectIsMessageUnread(s, { messageId }), [messageId])), changing messageId will keep returning the old message's unread status until some unread-store dispatch happens; the JSDoc example above suggests this pattern, so future MessageView usage can render stale unread/separator state on prop-only rerenders or recycled rows.

Useful? React with 👍 / 👎.

@danney-chun danney-chun marked this pull request as draft May 28, 2026 04:15
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