Skip to content

refactor(chat): give a row one render identity - #29607

Draft
chrisnojima wants to merge 3 commits into
nojima/HOTPOT-arch-02-thread-centeringfrom
nojima/HOTPOT-arch-03-row-identity
Draft

refactor(chat): give a row one render identity#29607
chrisnojima wants to merge 3 commits into
nojima/HOTPOT-arch-02-thread-centeringfrom
nojima/HOTPOT-arch-03-row-identity

Conversation

@chrisnojima

@chrisnojima chrisnojima commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Problem

Whether a row paints an author header — and therefore which recycling pool it joins — was computed twice per row per render, in two modules, from a mutable Map threaded through a context.

getMessageShowUsername had a pure signature but a hidden write: shownCache.set(ordinal, showUsername). Whichever caller ran first decided what the other saw.

  • list-area/index.tsx reached past the thread-context interface to useConversationThreadStore().getState() to call it for getItemType.
  • wrapper/wrapper.tsx called it again inside a shallow-compared selector — different subscription semantics over the same mutating cache.
  • A third caller, separator.tsx, also wrote to the shared Map.

The code documented the hazard itself:

Must be the same sticky cache the rows render with (wrapper.tsx): without it, a row that keeps its sticky header after a scroll-back load would be typed headerless here, mixing tall headered rows into the headerless pool and poisoning that pool's height average.

Change

messages/row-identity.tsx owns the derivation:

useRowIdentity(ordinal)  // → {poolKey, reserveHeader, showUsername}
useRowPoolKey()          // for getItemType
getRowIdentity(store, snapshot, ordinal)  // non-hook

All entry points go through one derivation memoized per store snapshot in a WeakMap, so the callers cannot be told different things. getMessageShowUsername keeps its pure signature and loses the write — it returns {provisional, showUsername}, and the sticky record lives module-private, keyed per conversation and reset on the window generation rather than by a hand-placed clear().

ShownUsernameCacheContext is gone from thread-context. list-area no longer imports useConversationThreadStore, useCurrentUserState or getMessageRowType.

Validation

lint:all clean — 0 bailed out, 0 whole-props deps, tsc clean both projects.
jest --runInBand231 suites / 2262 tests.

row-metadata.test.ts keeps the pure half. row-identity.test.tsx covers the sticky half plus the previously-unreachable failure mode: the row and the list are told the same thing whichever asks first. All six mutation-checked.

Copilot AI 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.

🟡 Changes recommended

Reactive username updates can again make row rendering and recycling-pool identities disagree, and the ordering regression test does not exercise its claimed scenario.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Centralizes chat-row header and recycling-pool identity to keep rendering decisions consistent.

Changes:

  • Adds snapshot-memoized row identity derivation.
  • Removes the shared mutable username-header context.
  • Adds/refactors row identity tests.
File summaries
File Description
thread-context.tsx Removes header-cache context.
wrapper/wrapper.tsx Uses centralized row identity.
separator.tsx Uses shared identity derivation.
row-metadata.tsx Makes header metadata derivation pure.
row-metadata.test.ts Tests provisional metadata behavior.
row-identity.tsx Implements identity caching and hooks.
row-identity.test.tsx Tests sticky headers and pool keys.
list-area/index.tsx Uses centralized recycling-pool keys.
Review details
  • Files reviewed: 8/8 changed files
  • Comments generated: 4
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

scrollDirection: 'none',
})
})
expect(result.current.poolKeyFor(ord(702))).toBe('text:hdr')
snapshot: ConversationThreadState,
ordinal: T.Chat.Ordinal
): RowIdentity => {
const you = useCurrentUserState.getState().username
Comment thread shared/chat/conversation/messages/row-identity.tsx Outdated
<ConversationThreadStoreContext value={p.store}>
<ShownUsernameCacheContext value={p.shownUsernameCache}>{p.children}</ShownUsernameCacheContext>
</ConversationThreadStoreContext>
<ConversationThreadStoreContext value={p.store}>{p.children}</ConversationThreadStoreContext>
chrisnojima and others added 3 commits September 11, 2026 10:10
"Does this row paint an author header, and therefore which recycling pool
does it join" was computed twice per row per render, in two modules, from a
mutable Map passed through a context - and a third time in the separator,
which wrote to that same Map on the way past. getMessageShowUsername had a
pure signature and a hidden write, so whichever caller ran first decided what
the others saw: getItemType read the live store, the row read its own
selector's snapshot, and a row typed headerless while it renders a header
lands in the headerless pool and poisons that pool's height average. The code
documented the hazard rather than removing it.

row-identity.tsx owns it. useRowIdentity(ordinal) for a row, useRowPoolKey()
for getItemType, and one derivation behind both, memoized per store snapshot -
so the two callers cannot be told different things about the same row, whoever
asks first. The sticky header record is private to the module, per
conversation, and reset with the window generation rather than by a hand-
placed clear inside messagesClear.

getMessageShowUsername keeps its pure signature and loses the write: it now
answers what the row shows and whether that answer is firm enough to remember
(`provisional`), which is the half that was always pure. list-area no longer
reaches past the thread-context interface to the store, the shown-username
cache, the current user and getMessageRowType just to answer getItemType, and
ShownUsernameCacheContext is gone from thread-context.

row-metadata.test.ts keeps the pure half, now asserting `provisional`
directly. The sticky half moves to row-identity.test.tsx, along with the
failure mode that was unreachable while the cache was shared: the row and the
list must be told the same thing, whichever asks first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rccpV5nLxxC5opF5xzrz7
The architecture doc still routed thread loads through `loadMoreMessages` ->
`loadConversationThreadMessages` in thread-load.tsx and described
`ShownUsernameCacheContext` as a sibling of the thread store. Both are gone: the
loads go through requestWindow/runThreadWindowLoad in thread-window.tsx, and the
sticky header record is a module-level cache in row-identity.tsx keyed on the
store and thrown away with the window's generation.

thread-load.tsx is still there and still worth naming, but it no longer holds
the RPC - what is left is what both the window and the row derivation draw on:
meta and current-user lookups, exploding-mode-from-gregor, snapshot accessors,
pagination sizing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RYPqG8z11HmGRn5MHSu89S
`you` was read off the store inside the derivation, which put it outside every
subscription that depends on it. The wrapper re-renders through its own username
subscription, but the separator's thread selector does not re-run and
getItemType keeps the same callback - so a change while the thread stayed
mounted put the painted header, the separator and the recycling pool back into
exactly the three-way disagreement this module exists to make impossible. It is
passed in now, and all three entry points subscribe to it.

The cache was thrown away wholesale on that change, which is the other half of
the same mistake: it reads as "this is handled" while collapsing every reserved
header in the thread at once. `you` reaches almost nothing - one row type
suppresses its header when the invitee is you - so only the rows that answer
differently under the new name give up their reserved height. Which ones those
are is asked of the derivation rather than spelled out against the one case that
reads `you` today, so a second one cannot quietly go stale there. They do give it
up rather than keeping the gap: unlike the scroll-back load this record exists to
smooth over, a different signed-in user is a real change in what the row is, and
holding space for a header that is never coming back is not a kindness.

The ordering test did not test ordering. `act` flushes renders, so a single hook
holding both callers has always already computed the row by the time the pool is
asked. The real shape - and the one getItemType actually runs in - is a row the
list types while nothing renders it, so the pool consumer and the row consumer
mount separately, with the sticky record written by the list's question and the
row arriving afterwards to agree with it.

RowIdentity goes file-local; nothing outside the module names it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RYPqG8z11HmGRn5MHSu89S
@chrisnojima
chrisnojima force-pushed the nojima/HOTPOT-arch-03-row-identity branch from 938c259 to 76e9376 Compare September 11, 2026 14:19
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.

2 participants