Repository navigation
Add useLogicalGroupKey() so rows re-render when their logical group changes - #5966
Conversation
…edged state useLiveRegion and the typing indicator queued live region nodes in useMemo, the "New messages" button marked activities as read and the transcript marked them as acknowledged in useMemo. React development builds log "Cannot update a component while rendering a different component" for each. Move the updates into layout effects (still before paint) and queue a live region node once per deps change, also in Strict Mode.
Part grouping registers itself while rendering and its activities read their group while rendering. The map was also kept in a state that was never read, so the registration updated <ActivityLogicalGroupingComposer> while rendering.
…component" in tests
…ut effects re-render A rehydrated store has activities before the graph is created, but the graph only added them on the next dispatch, and <GraphProvider> synchronized in a passive effect. Queuing the rehydrated notification to the live region in a layout effect re-renders the transcript (useActivities) after passive effects but before the graph state update, and the development assertion found 0 activities in the graph and 1 in Redux. - createGraphFromStore: add the store's activities when the graph is created - GraphProvider: subscribe and synchronize in a layout effect, so the graph state is updated in the same render as other layout effect updates
Activities subscribe to their own logical group key and re-render only when it changes. Part grouping adds its group in a layout effect instead of while rendering, so the group comes back after Strict Mode cleans up and re-runs effects. The group reads its own descendant ID by its group key.
The filter compared group keys with activity keys, so no activity was ever removed.
…roup-key-hook # Conflicts: # packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts
| export { default as useGetLogicalGroupKey } from './useGetLogicalGroupKey'; | ||
| export { default as useGetGroupState } from './useGetGroupState'; |
There was a problem hiding this comment.
Remove useGetLogicalGroupKey, everyone should use useLogicalGroupKey() so they get proper subscription.
Do the same treatment for useGetGroupState -> useGroupState(groupKey: string): readonly [...].
There was a problem hiding this comment.
Done in f1f2578. index.ts now exports useLogicalGroupKey(activityKey) and useGroupState(groupKey): readonly [GroupState | undefined]; both subscribe. Group state is a frozen value on the registration now (not a getter), and the part grouping re-registers from its layout effect when it collapses or expands, which notifies useGroupState(). Nothing renders from the group state yet (the only reader is keyboard navigation, below), so useGroupState() has no caller outside its spec.
One exception, on purpose: TranscriptFocusComposer's arrow-key handling looks up the group and state of other activities (the next or previous one) while handling the key press, and renders nothing from them. It reads them through private getters in ActivityLogicalGrouping/private/ (not exported from index.ts, JSDoc says why). A hook cannot subscribe to "whichever activity is next", and subscribing the composer to the whole map would re-render it and every focus consumer on each grouping change.
There was a problem hiding this comment.
See this comment: index.ts exports no getter hooks; useLogicalGroupKey() subscribes. I dropped useGroupIsCollapsed() until a component renders from it; keyboard navigation reads isCollapsed from the store at key-press time.
| const logicalGroupingsRef = useRef<Map<string, LogicalGrouping>>(new Map()); | ||
| // A ref, not a state: part grouping adds itself while rendering, and its activities read their group while rendering. | ||
| const activityToGroupMapRef = useRef<ReadonlyMap<string, string | undefined>>(new Map()); | ||
| const listenersRef = useRef<Set<() => void>>(new Set()); |
There was a problem hiding this comment.
For performance optimization, listener should register its interest (what activity ID or kinda it is interested in), then on notify(), it will only notify listener who is interested in.
There was a problem hiding this comment.
Done in f1f2578 and b9d83a6. Listeners now subscribe by key: subscribeLogicalGroupKey(activityKey, listener) and subscribeGroupState(groupKey, listener). On add/remove, the composer diffs the activity-to-group map and notifies only the activities whose group changed; the group's listeners are notified only when its state object changes or it is removed. ActivityLogicalGroupingComposer.spec.tsx checks each case (add, add an activity, move an activity to another group, collapse, remove).
There was a problem hiding this comment.
Do the same treatment, useGroupDescendantIdByGroupKey(groupKey?: string): readonly [string | undefined]. And make sure caller of this hook will get re-rendered by the result changed.
Consider building a utility so all the hooks modified in this PR can share the same code that do listener registration and notification.
There was a problem hiding this comment.
Done in f1f2578 and b9d83a6: useGroupDescendantIdByGroupKey(groupKey?): readonly [string | undefined]. The shared utility is hooks/internal/keyedSubscription/ (createKeyedSubscription() + useKeyedSubscriptionValue()); useLogicalGroupKey() and useGroupState() are one line each on top of it.
useGroupDescendantIdByGroupKey() does not use it: the ID is derived from groupKey and the transcript's per-instance prefix, which never changes, so it can only change when the argument or the context changes, and both already re-render the caller. A subscription would have nothing to notify. If you want the same for useGetDescendantIdByActivityKey() (ActivityRow), I can do it here or in a follow-up.
- Remove useGetLogicalGroupKey() and useGetGroupState() from the provider exports; TranscriptFocusComposer reads them at key-press time through private getters - Add useGroupState(groupKey), notified when a part group collapses or expands (re-registered from a layout effect) - Listeners register for an activity key or group key; add/remove notify only the keys that changed (createKeyedSubscription, useKeyedSubscriptionValue) - Replace useGetGroupDescendantIdByGroupKey() with useGroupDescendantIdByGroupKey(groupKey)
…ctivity between groups
| const [, forceRender] = useReducer(increment, 0); | ||
|
|
||
| const value = getValue(key); | ||
| const valueRef = useRef<T>(value); |
There was a problem hiding this comment.
Can it use useRefFrom?
There was a problem hiding this comment.
No ref is left after switching to useState (1e884ff, see the other thread), so useRefFrom is no longer needed here.
| key: K, | ||
| getValue: (key: K) => T | ||
| ): readonly [T] { | ||
| const [, forceRender] = useReducer(increment, 0); |
There was a problem hiding this comment.
Will this function simpler if we use useState(value) instead of forceRender() and useRef?
There was a problem hiding this comment.
Done in 1e884ff and 6adb788: the value is read in render, and useState holds a frozen [key, value] tuple that only triggers re-renders. A plain value was not enough: on a key switch, if the new key's value changed to the old key's value before subscribing, React saw an equal state and skipped the re-render. Trade-off: after a re-render, the next notification without a change calls the component function once more before React bails out (no children or effects re-render).
| function usePartGroupingLogicalGroup({ activityKeys, isCollapsed }: UsePartGroupingLogicalGroupOptions): string { | ||
| // eslint-disable-next-line no-magic-numbers | ||
| const [partGroupKey, _setPartKey, partKeyRef] = useStateWithRef(() => `part-${random().toString(36).slice(2, 11)}`); | ||
| const [partGroupKey] = useState(() => `part-${random().toString(36).slice(2, 11)}`); |
There was a problem hiding this comment.
| const [partGroupKey] = useState(() => `part-${random().toString(36).slice(2, 11)}`); | |
| const partGroupKey = useMemo(() => `part-${random().toString(36).slice(2, 11)}`, []); |
They are the same, but useMemo feels lighter and more straightforward.
…nd a forced render Test switching the key and function values.
…tric with removeLogicalGrouping(key) It adds or replaces the grouping with the same key. LogicalGrouping no longer carries the key.
…ject useGroupState() becomes useGroupIsCollapsed(): readonly [boolean | undefined], notified only when the boolean changes.
| * The ID is derived from the group key and the transcript instance, so the component re-renders when either changes. | ||
| */ | ||
| export default function useGroupDescendantIdByGroupKey(groupKey?: string | undefined): readonly [string | undefined] { | ||
| const { getGroupDescendantIdByGroupKey } = useTranscriptFocusContext(); |
There was a problem hiding this comment.
The getGroupDescendantIdByGroupKey function does not invalidate unless the prefix has changed.
That means, the result may not be updated through re-render. Use keyed subscription instead.
As we only read getGroupDescendantIdByGroupKey here, this hook can only based on the behavior of getGroupDescendantIdByGroupKey but not other stuff in the context.
We cannot know if other stuff would help invalidate the context because in this function, we never need to know how other stuff in the context work.
There was a problem hiding this comment.
You're right — the hook shouldn't rely on how the getter is built. Done in 6af523a: TranscriptFocusComposer owns a keyed subscription for group descendant IDs and notifies every subscribed group when the getter changes (only on mount today, as the prefix is per instance); the hook is now useKeyedSubscriptionValue() on top of it.
There was a problem hiding this comment.
Addressed differently in 14e31ea (the 6af523a subscription is gone), see this comment: the hook reads the prefix value and derives the ID with useMemo, so it depends only on prefix and groupKey. No getter is left to invalidate, so no keyed subscription is needed.
…so a key switch never misses a change The state held the last set value (the previous key's). On a key switch, if the new key's value changed to the previous key's value before the reader subscribed, the re-check equaled the state and React bailed out, leaving the reader on a stale value. A frozen [key, value] tuple always differs after a key switch, and wrapping the value removes the function-form workaround.
…ying on how the getter invalidates TranscriptFocusComposer owns a keyed subscription for group descendant IDs and notifies every subscribed group when the getter changes (today, the prefix is per instance, so only on mount). The hook reads with useKeyedSubscriptionValue(), returning [undefined] without a group key. Adds notifyAll() to createKeyedSubscription().
…cendant ID helper, useMemo for stores
|
As discussed, #5966 now uses the keyed store: one |
Changelog Entry
Fixed
Description
In React Strict Mode, the effect cleanup unregistered part groups, so grouped messages showed their own avatar and keyboard navigation skipped the group. Groups now register from layout effects into a keyed store; each row subscribes to its group key.
Design
createKeyedStore()notifies a key (oneEventTargetevent type) only when its value changes; notuseSyncExternalStore(), which React 16.8/17 lack.Specific Changes
createKeyedStore,useKeyedStoreValue: newActivityLogicalGroupingComposer: two stores;setLogicalGrouping()replacesaddLogicalGrouping()useLogicalGroupKey(),useGroupDescendantIdByGroupKey(): new hooks, replacing theuseGet*()gettersTranscriptFocusComposer: reads the stores at key-press time; one descendant ID helperStackedLayout,PartGroupingActivity,usePartGroupingLogicalGroup: use the hookspart-grouping/strictMode.html: snapshot 1, avatar on the group only; 2–4, focus Part 2 → Part 1 → group.changelog/<PR#>-<slug>.mdReview Checklist
z-index)package.jsonandpackage-lock.jsonreviewed