From 616f762cd2efcef0bb1081aa5342748753784f10 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 22 Oct 2026 04:44:25 -0700 Subject: [PATCH 01/18] Fix state updates while rendering in the live region and read/acknowledged 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. --- ...ollToEndButton.noUpdateWhileRendering.html | 95 ++++++++++++++++++ ...ton.noUpdateWhileRendering.html.snap-1.png | Bin 0 -> 30607 bytes .../html2/toast/noUpdateWhileRendering.html | 89 ++++++++++++++++ .../noUpdateWhileRendering.html.snap-1.png | Bin 0 -> 6727 bytes ...tor.liveRegion.noUpdateWhileRendering.html | 79 +++++++++++++++ ...ion.noUpdateWhileRendering.html.snap-1.png | Bin 0 -> 4656 bytes packages/component/src/BasicTranscript.tsx | 14 +-- .../ChatHistory/private/ScrollToEndButton.tsx | 22 ++-- .../src/Transcript/LiveRegionTranscript.tsx | 4 +- .../private/useStateWithOptimisticRef.ts | 13 --- .../providers/LiveRegionTwin/useLiveRegion.ts | 19 +++- 11 files changed, 296 insertions(+), 39 deletions(-) create mode 100644 __tests__/html2/scrollToEndButton/scrollToEndButton.noUpdateWhileRendering.html create mode 100644 __tests__/html2/scrollToEndButton/scrollToEndButton.noUpdateWhileRendering.html.snap-1.png create mode 100644 __tests__/html2/toast/noUpdateWhileRendering.html create mode 100644 __tests__/html2/toast/noUpdateWhileRendering.html.snap-1.png create mode 100644 __tests__/html2/typing/typingIndicator.liveRegion.noUpdateWhileRendering.html create mode 100644 __tests__/html2/typing/typingIndicator.liveRegion.noUpdateWhileRendering.html.snap-1.png delete mode 100644 packages/component/src/providers/ActivityLogicalGrouping/private/useStateWithOptimisticRef.ts diff --git a/__tests__/html2/scrollToEndButton/scrollToEndButton.noUpdateWhileRendering.html b/__tests__/html2/scrollToEndButton/scrollToEndButton.noUpdateWhileRendering.html new file mode 100644 index 0000000000..efdb736b8a --- /dev/null +++ b/__tests__/html2/scrollToEndButton/scrollToEndButton.noUpdateWhileRendering.html @@ -0,0 +1,95 @@ + + + + + + +
+ + + + diff --git a/__tests__/html2/scrollToEndButton/scrollToEndButton.noUpdateWhileRendering.html.snap-1.png b/__tests__/html2/scrollToEndButton/scrollToEndButton.noUpdateWhileRendering.html.snap-1.png new file mode 100644 index 0000000000000000000000000000000000000000..e531ed934651d824b28a8dc5f50743fcbfe6a1e1 GIT binary patch literal 30607 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 + + + + + + + + +
+ + + + diff --git a/__tests__/html2/toast/noUpdateWhileRendering.html.snap-1.png b/__tests__/html2/toast/noUpdateWhileRendering.html.snap-1.png new file mode 100644 index 0000000000000000000000000000000000000000..e142cff088fc0e3d6e4f56d37e9f4cf3411fd4fa GIT binary patch literal 6727 zcmeHMS6Gu-w+0=+fl)-HH)j|@1Y{7Ao($rU5u^!Hq|Lz4A&5u`Ejo(SkWs4AL`0Mp zdJqUGq(~p>kdQzkog{=1YDmuK^PlHD=ReQ6KIdlc_TJyc-uqqe`>wUumu7DZIVmbH zDj*%ZwUwp1_}rqdvM|yumXyW+7u8tEpX$yxl?S>#@x5l6OrUSR<`I3n>)>^ zZ)M#wAbF?zN3Tq7+JDMBzcuJen0}> z0!NiBcQM|P=1Mcy8#=x6*Sm)Iiz)lXV;+&@L^y@H8sieTKHw+xAk#|d!H)|9qmPgO zDtYRhjp2hUz~szA0e5wSkkWZ@&?X*`#Pf@=&bC==AqRC>+@asX1m_;x%y1|(rO;eA zIj`K6U!Q-sP= zIP7zn`1m_kHAN|MQ>`VWuHB-U_6AZi)9%olfn?ghzlA%t8Hu)?5r<;}St?Mbryqt> z2cor2IIA{?PHwk1*=9NxxT`R|_a2^9(%-2EdpLHQ#c?5(UXku8U@!ML+U))S?RZ@q z+DGml-I5==udPrX1W3@~!va55k zA&GgjpwnxyUmHEQwxOET^zmT5Bq|XVkqwp`TVKGu2p@}HR)Vgu?^D2@2?zWRcCW@5 zwWTB>Vw#x+YHQQY`YCZ|*#I;eK8Wr6{3lI(HU_0sh{N9)3m{YAn?#yAsvEj^)r5Js z|NF3V8Tv5hqVfS<$ZQ0(WiaMH^qjLu(zG*rQ~yH-Ye&uIuz3_)AjuaDmH~iVLL^=yJXI%qaD}PA;_n=qDhS;ULyAGF>YH}i`%0USQTbm{h#fVqTszhy!rcqCg&XQMbJk#0aN_;axXjyTB_xU6Lh*Xaj)wGP!>C#to z&Z#r$h`hN%;*GN<%5!-hL+II84s)|YduguZtV>EXRmr|s8s=Mo2xz|PQQMYNxqY-D zwdtwS2g3Vaf}VeV3n5*yQj$nF2w-%od&_$rJZ(@(JcF91zuk!nc0xWKW{EH)#Xe-@ zYiZ;V+xi@7Cn{Cq<}D|xl-oks8Cl@Y;9QRHVTHPK;!Pr^%B8k(VA5unR5sHgS(z{; zA&ddFC(#kcO7$@hbq-$P_|7s_j9J0g2L()Q5H~Xi@Ij@S8=(w5K0Y z*mpKsXK-`Q#PB0eNC7B*So-AEOYezqy^3WZSsLWJ+n76jJN>yAweM_)2_{KX!`bOl zQ#Z^iYB1F`XoicJwDje!B6PwJFHPea9b?hm(DBXT$`YMOMyvhOqs7ZGe9ZteDHa`u zZs#NCo{-{huS#Zbz1=*b2y$I9?b9Nv#UWP&hy2wsTQ<1expGG?CRfW&i^GZ94 zn`bLC3r+pl2T`Jnk&JGYt!S}*hV48d;X&vJzPrWx_YX<&WpIXa+3Hf1Zo;avPx?u%& zr?Q|VgHOeuk1>-~7+{kcdm6@23f-HNCe@cv!|r>W6;`(M9C%3v55Me7c;kMyKOoy! zpT_2Xcp#G=L*oW#94PP?S%SJp5=>sV@OMUmQ^h_lE%iQ9(Zl&{pZFP((8<7T!yY9& z(C!Qg=71GP!~1 zTZwS&JXAuHUWKGY<5IGqV^vdQ$5HUM@EJ&k>h{X0hNU9G8%FDe!`~C1(qqW&eV2w_ zK&Sp_qSdvtUQ)bULRUxmh6nS8{Pw&1`770EitlnZpFL@=YMHP2{Z+3R=VjNQjSd>u z<2XvOw+^p{@xxNC?R!kFFMQ1?hlNZ8QG{C;Ps;k^Y@T=mS$T(XD75#v;3v))W0$i! zQ!zG)xT=9Mo0SM@mdKed+fc1ppj2%GiLTpJcOD;U>$NK6%)w^#GB!*$$r&T$!Dl~m zQ?=b&H~u6l8?x&ky|_|?o>c-BT|TO-6v0zo`X#{b5Ai?I`{Pw$>_^|ijoFVc=LH{} zQi>QTA1R3+{J#5$d^~=h5I(fv3S>esGtCQf0 zd+)34dETG4fn?h7X=^E75>(8_Z3Wahmfd*N4v5<2vZfgKn$u<>>4BDvrtrU)Hy5?s z{cj6>(xv{7vghAwpnoR+p!2mB{)Y$uVDJwQ{`D^WM+^T4TBxCe?+OTfyZN;j@SpGT z|3}iLG#RTLAAd1A$CL6(*3HU6fbtj?!)-Cwg>B5S8M#hz5NDaH>_?Xqv3IT5Q zQD|?((({0WBvJ0qNj?j)J2L@dANVb$w+xlxT$XxC>?`h}%Uk<00j@y5RF+ZX(tY}D zOVs)#orp%JJ*q5;yLi>M6m*1@v?Ia!M=``ObhfSW>X;oEfk-^q4jO%ZV^FIoYSL}! zg+4I^g%K84_Q3D_1l#&jo@E@|eY8W2p(HXfyJRXp9GJDQ*JZev?#(}<1?)2BN)xvF zG*O*fxh`HtU;eW2id(IiR!?dT8dD?x+!0OpO4?tfN~WAth@KZ^Jt1T%xeFu-EQ%6X z1Qiu<%6D?3-EjUosyhjvDHnluth5DAt#l>OKEc_5bhnq5Wpe4Na^#>c5!w;Py$xsK zWC%Ojt<%&uh0u$>hmC#%wuTFjTf>O>kIW8cm)NDYd4vHumCm|2V#>aGw#vIZNvCu3 z+V!T}Z3?4T%2G=1^=lj#In@bV zx!hi8UZj5M?$@zJ$|=OtYq)eN8kD^>6hnqBJCs}E)Vbr1cG@+Ds)n>oy(i)-ApgMv zs^i*3^;G@uz4H3O8(oJC;6~a;<5ffmQrYa@mrt_JSo+%=G(5r^e#GfX*MIuS9Kbxk z8y7X1)>gA!M=d&ks-^do36_4MKcF;m=c9-Uw(U*uL={9C_J`o!_qF@+irH#5>7|9x z_8=Amhy)P;p)2taPe0(S%NR(j6dOiRqlsa=b`X@42L5a{Y=S=r^6Ssplk-LN=V}rV zwXL&NR=F1)9*%(donNFZs-0_{jeFEf6B>W}_$Ltjt56-c#y^0)-8y3S3>z&wp}a6(ppVPze-QBlhtLoPT`R9LtioaQ4@E>jh=&is zjR>?ItWNvodonuN!#(LAe;vGz-?xsy zfA2?7iV6Mv3Mb{_At7c^fi6utSluN>+Bv<9H5fLh1{C42a#jsuO2+s1mI}kEX%)nX zy@eD);Q2{!YIG=)U3_fw_=TzaKNlUtcKX*8;I|+EPkk?`UIhUpZ}>(bTdV;up(!tJ zwrwApDL1wkN*E8)O2+^6Wx~Z9LBh7L#)Ki$CJb$X8~Lb^eo+3MGKjad3NY<++ZS@Toe_zBzzE zAl@Er$|KSSA+Jwm@D7>qiQ6txU8V3obF1%^O=FQ}I~Vqqyp;BQK1j z*7lc6DZoLgbAWff+b-92dgokwr*BRH+E(@#depjR&fK}>qp-qtNlH8uBY0dGfG%%O zW(9CZD(G+{67Wny4$vUnVa#=e5nChF8kt>(_`zR7Vavra#xV@jkjWa0Q$|069s_XS z0SI7isR(=_1KgcKd3VQNDN$I~if6oEmJ29yNVq?t`9R9qV=Dx(#Vl$Jq_;Pnsg7x;rHYMR9GTuzKm8PRQHP;ecP(YthR&EF7#VezdqHq zHPnq&nuiMr&G(E>D}%7E$(}C(HX69pv9Xx%ot6D`&)pbQ4Uj86vMAdQWHbydfh|3! zLIE4eK*q76zdNaB^P-tE=?+L`0;di5mzL2yXUbbe{hS^6=ssXDvS{hQvq6;wjj*+Ml>qYh7m{Vj zQB$6*?LZsWpXYvteW`;gbiX}=#M1-KGfmR@OJTLOJEFe< zic1Z~B7{%MpLO;asN8sDy7}QaK{-CuNfjy+p5w6|+k4U=v%xYnXzYzmCNy2f;+uXA zjG{v2McvaYvn}gDUb)rCDo29S^>UtdG|b-WEZLmhuhV?+ORdlAP2h_8C35?hbWPLr zUp`ACib`F?3TC+IY1ZDVSmcYfJr;TW8rA61;Dal$kp+CHPXixZ%bECKRdZjczQ8pN zHX}QZov}hg=R`vm54$yA)<<`{RJb+yF9Yyvqe*9)kT~szV|U`3Rn#RA3I>;IYA3t| zweo)R`9^~F;vTOxXSOAT@J&Ep%*`L<9N*mB&#xIj76S966gY>@HV5^c0ISLSIyc4^ z*A)mN_CHa>om>IeUmh;*7@ffM`vMHo4;uY(*yPD~a%aAcJ*1skGApE86m~8#k|Gk21UR;nhi4GNfN#cQlzd(F`ZGluJFcAJ)`yCe$*`Bu<39*z1 QRs?SRVtc*r+HZgU2j;r1!~g&Q literal 0 HcmV?d00001 diff --git a/__tests__/html2/typing/typingIndicator.liveRegion.noUpdateWhileRendering.html b/__tests__/html2/typing/typingIndicator.liveRegion.noUpdateWhileRendering.html new file mode 100644 index 0000000000..f88329898d --- /dev/null +++ b/__tests__/html2/typing/typingIndicator.liveRegion.noUpdateWhileRendering.html @@ -0,0 +1,79 @@ + + + + + + + + + +
+ + + + diff --git a/__tests__/html2/typing/typingIndicator.liveRegion.noUpdateWhileRendering.html.snap-1.png b/__tests__/html2/typing/typingIndicator.liveRegion.noUpdateWhileRendering.html.snap-1.png new file mode 100644 index 0000000000000000000000000000000000000000..fb78f33e6993e98d009c85d7a8f9bb69620b14c9 GIT binary patch literal 4656 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 KEQ?Nh-1;Y>=@A0} literal 0 HcmV?d00001 diff --git a/packages/component/src/BasicTranscript.tsx b/packages/component/src/BasicTranscript.tsx index a80949ff6e..763cc6ab73 100644 --- a/packages/component/src/BasicTranscript.tsx +++ b/packages/component/src/BasicTranscript.tsx @@ -8,6 +8,7 @@ import React, { Fragment, memo, useCallback, + useLayoutEffect, useMemo, useRef, type KeyboardEventHandler, @@ -493,15 +494,10 @@ const InternalTranscriptScrollable = ({ children, onFocusFiller }: InternalTrans const stickyChangedToTrue = prevSticky !== sticky && sticky; - useMemo( - () => - stickyChangedToTrue && - // TODO: [P2] Both `markActivityKeyAsRead` and `markAllAsAcknowledged` hook are setters of useState. - // This means, in a render loop, we will be calling setter and will cause another re-render. - // This is not trivial but we should think if there is a way to avoid this. - markAllAsAcknowledged(), - [markAllAsAcknowledged, stickyChangedToTrue] - ); + // `markAllAsAcknowledged` is a setter of , it cannot be called while rendering this component. + useLayoutEffect(() => { + stickyChangedToTrue && markAllAsAcknowledged(); + }, [markAllAsAcknowledged, stickyChangedToTrue]); // We need to check if `children` is `false` or not. // If `children` is `false`, React.Children.count(children) will still return 1 (truthy). diff --git a/packages/component/src/ChatHistory/private/ScrollToEndButton.tsx b/packages/component/src/ChatHistory/private/ScrollToEndButton.tsx index 0bf0f06dac..fdaebb7e10 100644 --- a/packages/component/src/ChatHistory/private/ScrollToEndButton.tsx +++ b/packages/component/src/ChatHistory/private/ScrollToEndButton.tsx @@ -1,5 +1,5 @@ import { hooks } from 'botframework-webchat-api'; -import React, { Fragment, memo, MutableRefObject, useCallback, useMemo } from 'react'; +import React, { Fragment, memo, MutableRefObject, useCallback, useLayoutEffect, useMemo } from 'react'; import { useAnimatingToEnd, useAtEnd, useScrollToEnd, useSticky } from 'react-scroll-to-bottom'; import { useRefFrom } from 'use-ref-from'; @@ -46,17 +46,17 @@ const useScrollToEndRenderResult = (terminatorRef: MutableRefObject { - // This code need to be careful reviewed as it will cause another render. The code should be converging. - // After we call `markActivityKeyAsRead`, everything will be read and nothing will be unread. - // That means, in next render, `unreadActivityKeys` will be emptied and the `markActivityKeyAsRead` will not get called again. - if (sticky && unreadActivityKeys.length) { - markActivityKeyAsRead(unreadActivityKeys[unreadActivityKeys.length - 1]); - - return []; - } + const nextUnreadActivityKeys = useMemo( + () => (sticky && unreadActivityKeys.length ? [] : unreadActivityKeys), + [sticky, unreadActivityKeys] + ); - return unreadActivityKeys; + // This code need to be careful reviewed as it will cause another render. The code should be converging. + // After we call `markActivityKeyAsRead`, everything will be read and nothing will be unread. + // That means, in next render, `unreadActivityKeys` will be emptied and the `markActivityKeyAsRead` will not get called again. + // Layout effect: updating while rendering this component is not allowed. + useLayoutEffect(() => { + sticky && unreadActivityKeys.length && markActivityKeyAsRead(unreadActivityKeys[unreadActivityKeys.length - 1]); }, [markActivityKeyAsRead, sticky, unreadActivityKeys]); const nextUnreadActivityKeysRef = useRefFrom(nextUnreadActivityKeys); diff --git a/packages/component/src/Transcript/LiveRegionTranscript.tsx b/packages/component/src/Transcript/LiveRegionTranscript.tsx index a56adb2524..8baf10d7e2 100644 --- a/packages/component/src/Transcript/LiveRegionTranscript.tsx +++ b/packages/component/src/Transcript/LiveRegionTranscript.tsx @@ -7,7 +7,7 @@ import LiveRegionActivity from '../LiveRegion/LiveRegionActivity'; import tabbableElements from '../Utils/tabbableElements'; import useLocalizeAccessKey from '../hooks/internal/useLocalizeAccessKey'; import useSuggestedActionsAccessKey from '../hooks/internal/useSuggestedActionsAccessKey'; -import { useQueueStaticElement } from '../providers/LiveRegionTwin'; +import { useLiveRegion, useQueueStaticElement } from '../providers/LiveRegionTwin'; import LiveRegionSendFailed from './LiveRegion/SendFailed'; import isPresentational from './LiveRegion/isPresentational'; import useTypistNames from './useTypistNames'; @@ -128,7 +128,7 @@ const LiveRegionTranscript = ({ activityElementMapRef }: LiveRegionTranscriptPro keyedActivities ]); - useMemo(() => typingIndicator && queueStaticElement(typingIndicator), [queueStaticElement, typingIndicator]); + useLiveRegion(() => typingIndicator, [typingIndicator]); return ; }; diff --git a/packages/component/src/providers/ActivityLogicalGrouping/private/useStateWithOptimisticRef.ts b/packages/component/src/providers/ActivityLogicalGrouping/private/useStateWithOptimisticRef.ts deleted file mode 100644 index 84dc83bb98..0000000000 --- a/packages/component/src/providers/ActivityLogicalGrouping/private/useStateWithOptimisticRef.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { useCallback, useRef, useState } from 'react'; - -export default function useStateWithOptimisticRef(initialState: T | (() => T)) { - const [state, setState] = useState(initialState); - const stateRef = useRef(state); - - const setStateWithRefUpdate = useCallback((newState: T) => { - setState(newState); - stateRef.current = newState; - }, []); - - return [state, setStateWithRefUpdate, stateRef] as const; -} diff --git a/packages/component/src/providers/LiveRegionTwin/useLiveRegion.ts b/packages/component/src/providers/LiveRegionTwin/useLiveRegion.ts index 7e9c70a300..739ac05077 100644 --- a/packages/component/src/providers/LiveRegionTwin/useLiveRegion.ts +++ b/packages/component/src/providers/LiveRegionTwin/useLiveRegion.ts @@ -1,6 +1,7 @@ -import { useMemo, type DependencyList, type ReactElement } from 'react'; +import { useLayoutEffect, useRef, type DependencyList, type ReactElement } from 'react'; import { useRefFrom } from 'use-ref-from'; +import shallowEquals from '../../Utils/shallowEquals'; import useQueueStaticElement from './useQueueStaticElement'; /** @@ -14,14 +15,24 @@ import useQueueStaticElement from './useQueueStaticElement'; export default function useLiveRegion( createNode: () => false | null | ReactElement | string | undefined, deps: DependencyList -) { +): void { const createNodeRef = useRefFrom(createNode); + const queuedDepsRef = useRef(); const queueStaticElement = useQueueStaticElement(); - useMemo(() => { + // Queue after render, not during render: updating while rendering another component is not allowed. + // Layout effect, so the node is in the live region before the next paint, as when it was queued during render. + useLayoutEffect(() => { + // Strict Mode runs the effect twice on mount with the same deps, the node should only be queued once. + if (queuedDepsRef.current && shallowEquals(queuedDepsRef.current, deps)) { + return; + } + + queuedDepsRef.current = deps; + const node = createNodeRef?.current(); node && queueStaticElement(node); - // eslint-disable-next-line react-hooks/exhaustive-deps, react-hooks/use-memo + // eslint-disable-next-line react-hooks/exhaustive-deps }, [...deps, createNodeRef, queueStaticElement]); } From e63c5229e984b24c75d693afc7f184ea04f33fb6 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 22 Oct 2026 04:44:25 -0700 Subject: [PATCH 02/18] Keep the activity-to-group map of part grouping in a ref 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 while rendering. --- .../ActivityLogicalGroupingComposer.ts | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts b/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts index d2e402724b..2fdb1b78c8 100644 --- a/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts +++ b/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts @@ -7,7 +7,6 @@ import ActivityLogicalGroupingContext, { type GroupState, type LogicalGrouping } from './private/ActivityLogicalGroupingContext'; -import useStateWithOptimisticRef from './private/useStateWithOptimisticRef'; const activityLogicalGroupingComposerPropsSchema = pipe( object({ @@ -22,9 +21,8 @@ const ActivityLogicalGroupingComposer = (props: ActivityLogicalGroupingComposerP const { children } = validateProps(activityLogicalGroupingComposerPropsSchema, props); const logicalGroupingsRef = useRef>(new Map()); - const [_activityToGroupMap, setActivityToGroupMap, activityToGroupMapRef] = useStateWithOptimisticRef< - ReadonlyMap - >(new Map()); + // A ref, not a state: part grouping adds itself while rendering, and its activities read their group while rendering. + const activityToGroupMapRef = useRef>(new Map()); const addLogicalGrouping = useCallback( (grouping: LogicalGrouping) => { @@ -35,24 +33,22 @@ const ActivityLogicalGroupingComposer = (props: ActivityLogicalGroupingComposerP const toRemoveSet = new Set(prevGroupingKeys).difference(new Set(grouping.activityKeys)); const toAddSet = new Set(grouping.activityKeys).difference(new Set(prevGroupingKeys)); - setActivityToGroupMap( - new Map( - [].concat( - Array.from(activityToGroupMapRef.current).filter(([, value]) => !!value && !toRemoveSet.has(value)), - Array.from(toAddSet).map(activityKey => [activityKey, grouping.key] as const) - ) + activityToGroupMapRef.current = new Map( + [].concat( + Array.from(activityToGroupMapRef.current).filter(([, value]) => !!value && !toRemoveSet.has(value)), + Array.from(toAddSet).map(activityKey => [activityKey, grouping.key] as const) ) ); }, - [activityToGroupMapRef, setActivityToGroupMap] + [activityToGroupMapRef] ); const removeLogicalGrouping = useCallback( (key: string) => { logicalGroupingsRef.current.delete(key); - setActivityToGroupMap(new Map([...activityToGroupMapRef.current].filter(([, value]) => value !== key))); + activityToGroupMapRef.current = new Map([...activityToGroupMapRef.current].filter(([, value]) => value !== key)); }, - [activityToGroupMapRef, setActivityToGroupMap] + [activityToGroupMapRef] ); const getLogicalGroupKey = useCallback( From d20a9f65b9b71a4bb446758c058e8b1c3214f250 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 22 Oct 2026 04:44:25 -0700 Subject: [PATCH 03/18] Stop ignoring "Cannot update a component while rendering a different component" in tests --- .../partGroupingShouldUseMaxTimestamp.html | 2 -- __tests__/html2/themeProvider/override.html | 18 ++++++------------ .../test/harness/src/host/common/host/done.js | 5 +---- .../src/globals/testHelpers/hideKnownError.js | 5 +---- 4 files changed, 8 insertions(+), 22 deletions(-) diff --git a/__tests__/html2/activityOrdering/partGroupingShouldUseMaxTimestamp.html b/__tests__/html2/activityOrdering/partGroupingShouldUseMaxTimestamp.html index 04b302d31e..adefcf09cb 100644 --- a/__tests__/html2/activityOrdering/partGroupingShouldUseMaxTimestamp.html +++ b/__tests__/html2/activityOrdering/partGroupingShouldUseMaxTimestamp.html @@ -24,10 +24,8 @@ import { version } from 'react'; const IGNORING_KEYWORDS = [ - 'Cannot update a component', 'Support for defaultProps', 'ReactDOM.render is no longer supported', - 'To locate the bad setState()', 'has been deprecated, use `import {' ]; diff --git a/__tests__/html2/themeProvider/override.html b/__tests__/html2/themeProvider/override.html index ca18774bb9..1e69e12434 100644 --- a/__tests__/html2/themeProvider/override.html +++ b/__tests__/html2/themeProvider/override.html @@ -54,10 +54,7 @@ !( message.includes( 'Support for defaultProps will be removed from function components in a future major release. Use JavaScript default parameters instead.' - ) || - message.includes('ReactDOM.render is no longer supported in React 18. Use createRoot instead.') || - // TODO: [P0] We should fix the "Cannot update a component while rendering a different component" error. - (message.includes('Cannot update a component') && message.includes('while rendering a different component')) + ) || message.includes('ReactDOM.render is no longer supported in React 18. Use createRoot instead.') ) ) { consoleError(...args); @@ -108,14 +105,11 @@ stylesRoot, variant: 'fluent' }, - createElement( - ReactWebChat, - { - className: 'webchat-with-overrides', - directLine, - store - } - ) + createElement(ReactWebChat, { + className: 'webchat-with-overrides', + directLine, + store + }) ) ) ) diff --git a/packages/test/harness/src/host/common/host/done.js b/packages/test/harness/src/host/common/host/done.js index 0d2e81e460..776d1451cb 100644 --- a/packages/test/harness/src/host/common/host/done.js +++ b/packages/test/harness/src/host/common/host/done.js @@ -25,10 +25,7 @@ module.exports = (webDriver, resolve) => !( message.includes( 'Support for defaultProps will be removed from function components in a future major release. Use JavaScript default parameters instead.' - ) || - message.includes('ReactDOM.render is no longer supported in React 18. Use createRoot instead.') || - // TODO: [P0] We should fix the "Cannot update a component while rendering a different component" error. - (message.includes('Cannot update a component') && message.includes('while rendering a different component')) + ) || message.includes('ReactDOM.render is no longer supported in React 18. Use createRoot instead.') ) ); diff --git a/packages/test/page-object/src/globals/testHelpers/hideKnownError.js b/packages/test/page-object/src/globals/testHelpers/hideKnownError.js index 608f3c5e62..9d9f89eb2b 100644 --- a/packages/test/page-object/src/globals/testHelpers/hideKnownError.js +++ b/packages/test/page-object/src/globals/testHelpers/hideKnownError.js @@ -9,10 +9,7 @@ export default function hideKnownError() { !( message.includes( 'Support for defaultProps will be removed from function components in a future major release. Use JavaScript default parameters instead.' - ) || - message.includes('ReactDOM.render is no longer supported in React 18. Use createRoot instead.') || - // TODO: [P0] We should fix the "Cannot update a component while rendering a different component" error. - (message.includes('Cannot update a component') && message.includes('while rendering a different component')) + ) || message.includes('ReactDOM.render is no longer supported in React 18. Use createRoot instead.') ) ) { consoleError(...args); From 1121f33e31366afa61acfd9e6c1f012143d69fb7 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 22 Oct 2026 04:48:16 -0700 Subject: [PATCH 04/18] Add the changelog fragment --- .changelog/5958-fix-render-phase-updates.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .changelog/5958-fix-render-phase-updates.md diff --git a/.changelog/5958-fix-render-phase-updates.md b/.changelog/5958-fix-render-phase-updates.md new file mode 100644 index 0000000000..fa106d3dc6 --- /dev/null +++ b/.changelog/5958-fix-render-phase-updates.md @@ -0,0 +1,3 @@ +### Fixed + +- Fixed state updates while rendering that React development builds logged as "Cannot update a component while rendering a different component", from the toaster, the typing indicator, the "New messages" button, and part grouping, in PR [#5958](https://github.com/microsoft/BotFramework-WebChat/pull/5958), by [@OEvgeny](https://github.com/OEvgeny) From c4d28ede992aa009eb8fe22de85aff2667a6ba54 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 22 Oct 2026 06:18:36 -0700 Subject: [PATCH 05/18] Sync activities already in the store into the graph before other layout effects re-render A rehydrated store has activities before the graph is created, but the graph only added them on the next dispatch, and 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 --- packages/api-graph/src/private/GraphProvider.tsx | 6 ++++-- packages/core/src/graph/createGraphFromStore.ts | 9 +++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/api-graph/src/private/GraphProvider.tsx b/packages/api-graph/src/private/GraphProvider.tsx index 7872eaf540..116567b015 100644 --- a/packages/api-graph/src/private/GraphProvider.tsx +++ b/packages/api-graph/src/private/GraphProvider.tsx @@ -7,7 +7,7 @@ import { type GraphSubscriber, type Identifier } from 'botframework-webchat-core/graph.js'; -import React, { memo, useEffect, useMemo, useState } from 'react'; +import React, { memo, useLayoutEffect, useMemo, useState } from 'react'; import { custom, function_, is, object, optional, parse, pipe, readonly, type InferInput } from 'valibot'; import GraphContext, { graphContextSchema, GraphContextType } from './GraphContext'; @@ -33,7 +33,9 @@ function GraphProvider(props: GraphProviderProps) { const [orderedActivityNodes, setOrderedActivityNodes] = useState(EMPTY_ARRAY); - useEffect(() => { + // Layout effect: activities already in the store (e.g. rehydrated) must be in this state before the re-render of any other layout effect. + // Otherwise, that re-render would see activities in Redux which are not in the graph yet. + useLayoutEffect(() => { // Sync between graph and `orderedActivities`. const handleChange: GraphSubscriber = record => { const state = graph.getState(); diff --git a/packages/core/src/graph/createGraphFromStore.ts b/packages/core/src/graph/createGraphFromStore.ts index 8436473028..f1278b4753 100644 --- a/packages/core/src/graph/createGraphFromStore.ts +++ b/packages/core/src/graph/createGraphFromStore.ts @@ -33,7 +33,7 @@ function createGraphFromStore(store: ReturnType): SlantGraph graph.act(graph => graph.upsert(othersAudience, channelAudience, selfAudience)); - store.subscribe(() => { + const handleStoreChange = () => { const { activities }: { activities: readonly Activity[] } = store.getState(); if (Object.is(activities, prevActivities)) { @@ -113,7 +113,12 @@ function createGraphFromStore(store: ReturnType): SlantGraph }); prevActivities = activities; - }); + }; + + store.subscribe(handleStoreChange); + + // Activities already in the store (e.g. rehydrated) are added now, not on the next dispatch. + handleStoreChange(); return graph; } From 3a4813a1a2be9af8e8437c11e85257d2975ef24b Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 22 Oct 2026 15:14:49 -0700 Subject: [PATCH 06/18] Add useLogicalGroupKey() and register part groups in a layout effect 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. --- __tests__/html2/part-grouping/strictMode.html | 116 ++++++++++++++++++ .../part-grouping/strictMode.html.snap-1.png | Bin 0 -> 10342 bytes .../part-grouping/strictMode.html.snap-2.png | Bin 0 -> 10703 bytes .../part-grouping/strictMode.html.snap-3.png | Bin 0 -> 10702 bytes .../part-grouping/strictMode.html.snap-4.png | Bin 0 -> 12161 bytes .../component/src/Activity/StackedLayout.tsx | 6 +- .../private/PartGroupingActivity.tsx | 16 +-- .../private/usePartGroupingLogicalGroup.ts | 29 ++--- .../ActivityLogicalGroupingComposer.ts | 45 +++++-- .../ActivityLogicalGrouping/index.ts | 1 + .../private/ActivityLogicalGroupingContext.ts | 1 + .../useLogicalGroupKey.ts | 45 +++++++ .../TranscriptFocusComposer.tsx | 9 +- .../TranscriptFocus/private/Context.ts | 2 +- .../useGetGroupDescendantIdByActivityKey.ts | 5 - .../useGetGroupDescendantIdByGroupKey.ts | 5 + 16 files changed, 227 insertions(+), 53 deletions(-) create mode 100644 __tests__/html2/part-grouping/strictMode.html create mode 100644 __tests__/html2/part-grouping/strictMode.html.snap-1.png create mode 100644 __tests__/html2/part-grouping/strictMode.html.snap-2.png create mode 100644 __tests__/html2/part-grouping/strictMode.html.snap-3.png create mode 100644 __tests__/html2/part-grouping/strictMode.html.snap-4.png create mode 100644 packages/component/src/providers/ActivityLogicalGrouping/useLogicalGroupKey.ts delete mode 100644 packages/component/src/providers/TranscriptFocus/useGetGroupDescendantIdByActivityKey.ts create mode 100644 packages/component/src/providers/TranscriptFocus/useGetGroupDescendantIdByGroupKey.ts diff --git a/__tests__/html2/part-grouping/strictMode.html b/__tests__/html2/part-grouping/strictMode.html new file mode 100644 index 0000000000..634034cd30 --- /dev/null +++ b/__tests__/html2/part-grouping/strictMode.html @@ -0,0 +1,116 @@ + + + + Part grouping: keeps the group after Strict Mode re-runs effects + + + + + + + + +
+ + + diff --git a/__tests__/html2/part-grouping/strictMode.html.snap-1.png b/__tests__/html2/part-grouping/strictMode.html.snap-1.png new file mode 100644 index 0000000000000000000000000000000000000000..f9949778b13d8b3433e4c30a9bbf14420c6eebdd GIT binary patch literal 10342 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 b0Sd?lb<7pzTf_ix1fs62tyKEpap->l)cGiD literal 0 HcmV?d00001 diff --git a/__tests__/html2/part-grouping/strictMode.html.snap-2.png b/__tests__/html2/part-grouping/strictMode.html.snap-2.png new file mode 100644 index 0000000000000000000000000000000000000000..39bfe37ad37638fa3c8c5408d29863bc88497ad3 GIT binary patch literal 10703 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 literal 0 HcmV?d00001 diff --git a/__tests__/html2/part-grouping/strictMode.html.snap-3.png b/__tests__/html2/part-grouping/strictMode.html.snap-3.png new file mode 100644 index 0000000000000000000000000000000000000000..adbc85e88d526251dbd54ad66012e536c43e69d5 GIT binary patch literal 10702 zcmeI2XH-+|)~=%nC&LK6g~BO)Lo zoxn@)C7}~SsL6SJ_t|@#Jx=@1&ojp6p9@LWn$Mi`zUDnwgs!$K6$O+60)bGeJ$k4Q zft<60K+eCqcphAl^G57IAXgx24;5hE=^N9R(tB})u3aIr+nvJf6yu83H72>YG#}lV&S6@0dfY>tIkLSNi4T-wN47qW3jhR8n(gX57>E(E}lrJ+x@qr8E z?rlj(%2j5_?~Bxs=NHH!BEclD7~9T$i<*NxQTflW{}Yz~q{Dy8;6L5rKV9+vGd1{p znKDL+t1qAxy;GgxI!Ti;fjHdnYcHy;)|_A8uY4KTRQrq0!My&F$4t85?K_;Dot>SW zckW0+7zJ+KS~y;>?9Y_-xRoyH;R zS~K!FjP=DBEQ6rj-WJWfuU++*3s}A|*K5jnK`O3=%^hwQttV~#oEzEUpYnY8_r*4{ zu5fp+GlXSSkLW}_^*qF!DwrZBD<)&}Y@95)``Eul0V_uRjb5mD?#5k51AoOekxGT3 z`#tYJPBOpZvz!>NlA+FEm64|<=XtHtKZzo??V6Eeoxb_zsfGD z%o*V}Doas%OjmZS#3Jc(Q(Rm;BdAI=*k+gzpGK4Jah;{BLfYqG6<@Am+wZyBOFB7) zSj?55GRES3?p3`Au5X|&3Nhl%>_4Y4zyn*=3D2hI)_sr><3Xkh?HDX6#mnSL5#>Y? zIGtk&`(=W>HUY16JL!7FFWkOtLGY~7Me+_Y4Fr`6H|Mc?LFbTg~&g zr;BV8-b+8@w#Jkar4bkp%!qw&su+v3hpt$odLnNKIn9BU2KKz;dNS1ESRTmb4hDC&zgKtCrI<{OnkShK$c{2LnCFOufeR zm1(j~^ZvdrwjlHI1TG!c@ZS>F-!HuJ#!PNZ)qnQfoG>gkLWSg}Cg%oWMoI`eyRzOp z*$t7tnv-9hTdmvsx6f;7YWi6j^?yH1llV`sYTw7uvYr1OSG|qaU z51REr6{v|@X2%Y@!1Hf+3Hz; zriG4VxTbjEsWVwZFZQJhh7}&3aZGWpN zz%LE(M};wAd`<9k0WJPJUoW8ZBMVb^frOMiqmovMy0QD6QpmeGg094|v@7SKNo!!h z*)h(_7hT|kUGZs+jEOn8KJvYJ_|-LqH=aawDh(PoIsblHd~szCYiRJ|R^#^QU#gVQ zrsA>p3(2fLZ^V_`1?2kWPRgXeQ&1X?9v^I$Iy8S+!?zO8Dl8i5_%MmwMX;L&MTW90 z8J&Nh7%zRl@ordKoo(7by#W4z$+y&Ah-N+I*k&X>h8x428y_OF=Xa zmRGAmj>TtrWXjNMgnZrT$=)@hrQhMe;F*MFy#f-5=J0Q>YXeu}nXUsF9D&12mez zqSVqCNrM}H9l6q<%aeGy=tW51W#2Hw)|6>S`CR2;2ZPlbudg__Fia2r*QXry{gR+% zq3SSb{R+SSX(DIeS&w5t`nJ@-Fjsdc@9Pq;xmNEIW1rrSB3)*>EU=vTrLUi8K(j32Pd#Jjk+jvu3i4G2HqPPeBn^~ZZAy}=tlz?c?bQNg5?Tg`aO@ESXD z80|dub$i0#&?&{wY;8E{-ovI@_F=ZP!HiP!c&N1s${V48;y9=rv{%hr7oCkDH&*V76PZ;whtgY~?Lj>3hV^F=(dt>#z9<=0F$L#R=1`P<4v4aX^c zs4kBdS>p@Xn2Q!39{b(#Tr|v*?~lVp+K!H)$4VKJu8PCI1p{6xB|b}EY)U6zoi=2Y9jW1uA zPoc4{MSX0t0hWA}mdOUhT(;1~#J!dZSJ-_SR zO_FCjeufn$T`?@~5XEbX*h}#_zkqC=C%2D?hyaf?!!>&Q<->;$z1jXkzt8y~n)M{~ zMYK}MReJgkv+{OLfBz;{Q=-J(1!0Q#9qm1FYn<4l=$*W-1WViJ7w0Vw=1rLke4!QI zwx2s|^_izXzlbHd^5IIVPRfNYzin_gp?5-8+9|(5Qr@V!Byj6}KX*~>VsH2YcLnGD zYSYEXJTXpFb%TFj`QL7oH!*ry`i9Q zSk3eUT()d6vgEnim*L!cb^xkc96tJTYpQ<6H{(^u& zt~}H#f7)dyle2eR+;Qnf3!u_zN-8xvTBsNEeQ#YpvEv)}p8mj~N^8UKC?zg+fi|-W zQd(?oh{b*=zk8wtE~zC!Gg^BRD;B1DqDD!NXo4OUA1L6!Qtl?BSw*ph#uU^OU@!rM$ML zCicMOq(|w$RlnMDw32qR)u16?Td8yPi~dY-lbTvJ&w7&j z#^(HR7(^F~m)$C$1j;7$y+zCl6_fZzRTnGPH*>nt3;zWs;xti%ZpP0r(b9^5+ySxN z-#dkuZtY=I4wIH_T&gk5-P_fpYb)jE_4+)Y{P0f7DdL%8a3c(U@Fdr! zV|K1B1R=1gRf2q-(!PBCPm#?X9!xr0NEdI~(`-(Djg5^Ri-5u1mVb#MFe89rVT%k@ zqBDUfn|Z3SNLjy0xT)5o$@L3c!rxz=BN;ih^xy3`Pf9UTW@?}X@BYB?dUf+jaqM0D zp*y$_q6B?R$wC1#dbAaU1BEm+co!WLGg@gGc=QZm*mUJ`{ZE2O*0)*Z)3W>wd23d7bZ+~4Mt2D+Mm<^EufbnWEXdpKD9yCVfSt|q9J!KGk zI@GU~CNA~P%&A`4g2dT0kN)hw+tS-WLGwD@X~v*8%HuiynsUgADx2_zGJ?)Nj8doi zHBqosBe!?t%`Q>BM`7l%vgRDsOiMuH-=VB8l*gmwOK&+m0tK^oJl`k5lsn^Hpq@zA zdFj$6X#Jh~ioP1b5IZ^-;N5& zmlYJ$Mm;5VEU|5(Ti+Zj(F;5l{WMydKJ=-NlzqnlGqOEU!CaweEnJ#Q2>wi&Zm6bc zvD@T>McQ7ux{J;FX?3snaswk~(2jr3Y7s~0R5wqS?4(Xx>ww7k5M>ijQ=LEw=%NFb zS|iM%R?Ooe%AUB;`JylVQqv7@iP|#jxPK;y-Bevwk1puT1~v!!?MIfH^TP2CSCBo& zLValxI`XAP!`>iklmYWwig2nplau46^%3e_t0HUR?+xsZ26=N$dq zy(wA1&qf-N-GmFLVblIl-;icGJ$6JEl4|B!H}$850uIp|c*ZUE*pMJbp2K=*=GULU@OzRuXFUN(Wy*{KDpc+<=y-DAI3bmK&Fh<*R{~`b0Cd{3(5IN#07OBOn zWsVd?Ox+=)W-dnSql9bSYt4FJd905L9L;GA-!uF2l2OjT?pOv%Q6#L9J6Twi>Xff8 z!t1ye83{?$&JYS1X&bZ{xT1y~Pu*7=qWvfr0Gzwlioy75la6GrOz-bzRsimwn+PWV zEhOQ;(gl^ktr|s}>bT+-_W8#WUKie1iD3raxPOUB9{XDax>K53d_Y@g8U6Wntg*Bn z-=5|3B!8M{sF;2kx0Pr>sSg#4h5I)_fn;f?YrM4%!7B8e?D@KF|wfPMSp>hY10pU zV}RT37wwNs!_w`8P2C^N_~J}IBwM@}0|B2ua>V&-!!E)|2)kWuXVc6X#6}NvZe4e= z7c1Nt(86WxXQZmQNa&6u&U$FkZn!X7=@|4*ebjr{>Xc_vwTrGSo^R9I;k_lKzz-Fl(1xgSm%%GiK02rV#-_OJBAxBu3a#1o*vj_OHz6RtxGuwVMq4O~4Acjo`0%iu0A?v;Opd;lt{%S`&@I%nH_b34U@i~kVmrl4~dP5(6b z$h^wtgVcYbfa;v8u^M0u024~ygEknV9fxi9onEdR^GA2hgU)aOL%=*wZT+w$M|E_b z;tFS0SI*DNr`Z;eHjNqoq0{>@chRozq5nVu{l%RY+be&6V?rw63LO{k5_Et^uDnA1 zPh~+e_d#Cp?GWJsfFq>u6aR+=si`-a>eHuBU%q^)s2HqtW7pEH&$!wA(svQb1gM^# zUR+!pwe=m~RK~uHyQR!5_;u&9^S@wci9Q(mUtY6rOa$av|4(#sxz>5MWox?8xXh#q zFf`Dj(~Zyl>2U|yX;6B4F^{zYnE3io!MlU)SNyJ8tx89uX3l3bt6+LupKT?ghl}82 z6@O~<#Xj(o03?VV9fyX7j+UGCr`%fv0}QLIuLt4uXtEYA;k9M{;@-dG%6otslVY;4 z?qs1^;A3nHNZ1Wz8x(0{yrP(;+yHm=Jq3x*Q(*;+Ct$4XEGJtbh4S7BJg(t3tzP>5 zQRIxAs`E7oIjEYk9HsENSV~)QIojnQ(bT$sG7I1kn}36r-?Ih--Bz1Hy9*t)4k*mz z*KHD-`*)xJPH|oAv~L0vVd^&)aQ|Mw?E#vB69i^xDS(eZe%$VwRR9h(?KNA@9`RIh zF$Q>p4h1S~w^gD{ntf|MGi{4MoYn#1PnQpBQB?N=CJiSD56-zv&=7!Pjr;T~21{=s zfRsElWC&{{U>Fef7g+(ol5L*A{)nKBpMAD^GV}nE_bY)XF972dxYx}!!}`a%gQJUr zvJ&}>59C5-8ySEBH<~e&1@podBwIF&2(;11%pJ0cg(<(go+xPXkT$Ib0=WeL^IpK? z9`Nw%e^o6K&Q4DD(O6LQrQl)SoWpJ~B61V-H#!D@tMHA!w>-f2tPaO3Od)ndeNU zRqMCXJMtAyGoP&6S^YMucnk{Havk_)k{miIDgk;+aB@BeI1H7>{XZd1l18GNmOOnB z2>57i%~y7%FuR*8F7!{0mR~sdIWzz(!wFQec7ETOObYOD5n~p{buQKxWmc^%sp+k< zY_h&EEnri#QV_0y`NE+a2(>;d2XXVCSfea3_6KS-*mKFO@>Hl8L~&O|CHLIa1q^Nr z89I6}%=lw_9Z<-U4oqmJ-cXNF>uC@X9~4BuX1%*E0QE+Yj%rCv=l3Rf>B!;|Zt;I& zru9y4B^<-g>PH&&a?uN=l`Y8m6kk3qH2*HmCpy~Y`t%Qz|VW#U}4$(7wL>Sb^|dqv^0$El;7Q$ zChOhftoJoHH=p~O#6`5zAqtGJsW;OT+0%uJ=-vrlB&DF+5h|5)D~Y73aQTg#%37!# zq3=(Vs8lDrdR3knaY}E%hZAGSMnb)IIM3@pg0F0tvfIeiQqRX)W<`cqO*tVKfF~3Ij zM~u*nt1L3d(jh$6N3UCjo~^gY8$R-}Ve`vm&0I8kgYC5? z7uo>hSlIv21?MGqwm%GeuI4cbm2fI!DjAAWXKKj3^`O9DRf7%T;=v&LaLE4)XTXR3 zhlM&>1b{y9{d|6--Q@_b%$?EF)8ChS3`6H0eRTNf@Y^@C)DHQgzVNc>@;$JH0}Bey zSrhE}f8kDBPFAXprRRD$&S!~3bUjG}h$f3;iUS3$OUwu~3h;Y$c_!{jH-z`#3Fo+9p;YCI4f6}7)& zhr$h{J2iT4!p>3oSB0qrsq0K@b$sMZk@4EvC^ydH@EIpHF0;azrN(*~)8NiWG6-d6 zQoT=i_$E&{%xg`{!v-Y_TF^<%+_-aA!-reE&Tn!>lEtRucAA*}k&K_Rt1t6m2hbHm zDl{0&G&O$%Cml?)`k*~%-Iuz_7);VyY|(fSdWjY`&27;DIJ3MKh{!#EJwE*(x}?Um ztm9>IE4pvcCjXUpp+4{Ub1D8iiXmnc0mcH2C$?cAK(yq_T?^zHxpWv$QI z!8S8ZJ|fS54=4hSgo@hD1%CmPBWMgueHoMIF5XsXUTOj>D5bE=py@}6q>8~uL9(*E z+PU^i_u1*;E=X{@k>a?dWPa0X01ALP0C@xeH3VZw#M`&U=Jjr1KPZ2)@fnQVS}AvP zfO^vN!gebBz;f>DZ58f_FOHMfnV3oi%xdg%a7Ee~$EVnSopec8@EO3<13NkYp?tND zNcumo8Q}>coDzD$;9ep3sKUG+_#=fWH|~48011!0E`Yqt3v3bq2mKi`5)LE9Kt+K* zyMmQrL*x*bPUd|O;XeG(Az0zn+7EZ0tMeD$n63gC*8>;_7*V-70VfB8gM-Nv`q9Kb zne7op7!3CMJn6#1LJXUHg?X`YWu_6Y!M>RVY=5mtdwtAs0;G}^AloqVoZJ+CY0R|ilsHfJf26=`gGS>K6HkBO^K*NjtL(BK3C zKS+Rer$QuUszj3Bf;U4V^$hgc4{A1gNmwc_2}SD)5iAuAR#GBC-13;1{bU^i)S9;- z5jZt+E_1K2RMMaa<&qkTif!gTElM&Rgv}}hL;}3yej0o zboK{iOzBlEun#pnr-!fsVD#AK&*z8nBF%Y)RAN~Jz^>7LKc}0nd?YFildMi*F;erh zGny&!YAW+bDfdE(EY(=nX5c{>a-foqk3fX)@BO@OuN_<*ChfZL13%-p4SbPEGG{-W zBuo_LI8g2M$_@D_?v`{CiHqs3f*j=X;Y~IO`>p@`yNu7=Ax|F0lDrxxl0YCtqOUb} h6TFp4_6NI2AlCQae!5w$A_A^J)ReU!RwzCR{%-?~?(_fv literal 0 HcmV?d00001 diff --git a/__tests__/html2/part-grouping/strictMode.html.snap-4.png b/__tests__/html2/part-grouping/strictMode.html.snap-4.png new file mode 100644 index 0000000000000000000000000000000000000000..a9523451b61ac9eae46cc10d0424ced9f57c39cf GIT binary patch literal 12161 zcmeHtS5%WFz8frK@-{J<-O7TFfW7Pe33LW*u#H z69|OP1p=Xee4HL!Q3^t=Kp>|fI_kI0f<7(`Gkt8^q&BXmT|d`yPLYFKuL-m87WOlN zox|GnswGo}XA2_hbLQEL8dS>n1n~;>OM0KLypi;ObL}Bh?DaE_$<*cc?bV3} zVf_!@``bQHMrLmRdS+Suq#ILJ%eAI{-yYLt=_Q8mEtawJ-@;D|9Bwy$e%R9f;kx4? z^}uawt~*CH^28UXwt+9saTpkK18}#}Gj#S(msFf5r0jEOcVk>Z6#IcV}^k`QF_ALi;LkSqfeox=HSF z`|3Gd8N5DSSr9@Zc?{!3p*0wcNw>QaCK-c?oBrzBoel1YU|kn}faX(lJ703yjkegR zO6Fi_%X_#xHk-DTb}JBa!QwVtdXz+}bU*3R;2KG4#l z(#8WgYX^)nlpt>D??}Sm!D6u(H9NbH7>u)()rV>Cr6GAJv?Fw9EtXI5rjU?kTTWVd z<=yX3i)wetxu!WJUIl*_VunVT1H?wKAQ1zWK2okJtAJ9z6WDG+LBSlOwD7TSbWnki zh2piTmcxT()M!;;5vC@DQfQXjwb1`1aB(2ZsmybjV4im~aA|09uq=n~k{cN;HkTzoU}y`cRZKxPZRsKE=H`OM^AAvuwLku-a=Elhc=lWnS06jcot=@*YLURV@@mPV8$ zm+Y0W^^lgj4N2`iSE*1gRkhNX131K?f9UXFtwm~NY$b{Q8{+Cci3crP?k?BN=#B<8*7Tdl(cNlck%(o;!s zF3ESY&)H59#&eI%TFgO?B#Pk(`FSc!HI2%gT?3PH{5-OA@oIVj)KBxX)`xXV+f?C$ z8IKVMg$$3)36vXWu$uaZ_+z@UD=XeTPxv6;vrQ!xe)^jTCApdi@VQK(ET&&mg5qpv4ace#0 z#ioPEd;F|lBBo}|dcd?^?@QMys4e5S(q?7OQ(l{iMbs{8tklhLp2;H#PC?6m+b|Uj z7virSX=L-O-QBQ(p=$<$hS<=g_(-@bbKZSBI=HDuCX!IH z3VDigr+%UEWih0wiKVjlN%la`VL!O|S;yR-o|w&+fv;xWZk|h%N|eM1uRLGaR_dkm zX4V^5Knrw*-fNI-SmSI?o~-)zOY|_TtEBhW-sXIwr0a4GC6%CFIUZj3r4Ds458ut( zv;Xrugq)k>JFUOpT~jt19^6|CC`0T6Q1J(?zafSza5>i`OLvX6ZnM`G9yA{L_KKc@YbWR$py(N! z_V?>Ju0>*X#n9u9MWu#+hJ0sRhuM@4mrAl4S50lIgOiPMmfp8OXD=HLNDic@ z^r^fx>95z}*+lYQh|$H$alfzMH#o15*X7gfbRg?ACySoB@j4xqe$z^(Iq2|!vRu2j z1nOcacqT>fy@b!Qk(?PZrSf324_>#{EY8exQ^F*(sfIF==aeD6Kcjq@8!c>$q12t3r8JUVbx<*znWRmLHwv;~<4Cm%i{76`mQwOx8sTbG!Fzc}}MxKD!+tWVU`%h8pt6Gp3o2wB@d>m_1WRri#Abr+DSAg={Tg9QHQ9 zJz*f%ZFd+*Y5$^|-s(q8@^&Ko_$*vKhiwUVQJ zpp&~gl?ZneLIdR99~lXnj0N|P=iF-Gg6pR#+0TTP1`Y{au5Qm_=RH&LQD(b)p-7jj z8MaHcHHg!lj6b$COT?X^pVBCDs7Xapcl>7BE0p;@G+jy%!3OfAK#I)sdLvks@UK?R zFNChQ$&#aI3qR*>%E6gluV%pqs<89B|TZgs} z9y>iGB(@4{<8DaE*_3&vi1(pV^8n*P&pfSA&oQebh1lXa4)*FZov-c)1(|ydh1tK<4crX zAHU=fpROs0us(FCK|+b5HO>#z9XT7<4d&4^Fm{uX26wcPx)Em<~CFn1EJXai$ehWDuD zo93V=cg9^PFM9Us6fyyoL);QHUc8TRBTaVl`p27p1 zGEz)cpGYxvX>%Cx{aRuzckjIJGx76w#IcH+X!W^27IA9PpWOQjg$yvK1e8J-2TGUH zPH>3*9qBx?RLd-K#>|B>l6!A1PMI3i$QOt&!ty_RiVHv3<=&f(IwnPiUfw4PN|kkH zyOcNJjM9|I$HgVgtZ6OUtibXt>|mq&Ijew0KHf6pqsbHf6pKy&jhoY%41PW(UCcXT zoE@EUv9ohG`WVeimdy){weBomMEZHZ8cNmn_b;LFBOG`$SvQw0iX0Ah=Ziv?E5@UE z-tiUHY+(+6CQ8A>0YFq&2}wxkLs_P@O=d{EMrY@ElR|8V>0lP?eNPIwpE+FS_Qxyc z8oqq1H_3ifD?{2x**@E+QI@H^!Iv3jCTi;IdT)1YaXyIPg`%#fnY;A$3z+LyJ0I(| zZJtpaFcY7CkRIah^f^x6trL4c1Z;k(`So7Bl5g-YLznqhsTw?VRm1$saESkkIMm-47TvE18gksomXzK0CgIAWR%|4KJiJQM zf~FgX&U$F3p#MgwVbjUUzxX^Uw<828SFlg5e%LcEVYwu0 zJJ$3@kh=*VN3PMj0vpDq5-9vNIf#Wn#M_@-UuRr;`s+C1zKiUf{k>nW&NCHV-oW0A zjFKjV%?4R$tb-d-Kj9lmaph)wW%GQzk&=%Wo`ul4x|7$=7{?>qKGXgj_VLuro`zC1 z)h28qQe2kR7K=*Rc&99}v1e&B)sf=+{^6q38Zl3I*>b+G$cu!36Y*~L5#I2b;wNn% z+z#)3Cdcn9k~3#2)J7NHFYP(mbZ|e()a1muRad5y&xR}$2ho~+qTy#*xA~4D(*p`D z*YcK8x^;9d@PT)zPwfWnn_7-{Q}Cu04qo)!nfU!|a>|M6qbE82AG;@v_mL-2K7iNu zR~`@zKMmWL{&_1r|NKa(s2V2^0pE=!09mJr8+Y`N5UDYMy zhSR*hYdJ9p2OHIt0dzA>>Y?s38D1iQ#s&Gd;UFJfa$9hTl?!}9{CPBAA9 z)a;Txd%ol>UUG$nB0IRdLq^_9zgtIZ*8q*2q8PaJGn(x;N}i3^2(HFM23k#_Z6VV@ zY?QnQjeTU1+mzSB+B&d(aAk5CI@Od~5E5ZhKk%-|>glM8ip|`qcTr+QB9R=vuIKr? zB|$uKm)rFaD%5B*xe|C;$aA5~%PBwZ#NWn;>(i|xWxw0hY!albuNerMxbdn~p78aN zIiH5&k@jjKwmrXpza5U=*;z|kPeeFBxh1~=$5zWPiNMQE7kTF2i;f*(_)eE8zAja9 z1v%w%f%}~}{*V`9jYdg$EBvRAF5v=2zfzB7(6)3vC`Ce6oU44v?Y{g%dc{L-OSdEs+0pl;j;N^(Q0tiLz` zXT-S9aqaJKK#+VT0Cjm!O4CI1_5;zcyg$wgIGleA(L&hpqljgp9Z>K39VauzU*Zl; zKi&PVxKus~d>&7~j#L;dWUG4d>mVq=&TNBZsC#cYWxlP>EZ2lb*3-;#A~z?sa=x%C z8-R@CuUG1ff-lK;aGDiw-t5aS$0_aNX_)j@L`Y^yE_f|-i8Sjt@iQ7Y0DZinQDW4N zWI-)7xGmRjSsboCgr4>k#TvFey`J>j3~klGvxqpxTnV-$qb2>Seh+AaSXcNyq-abPHatCgvczN{ z)23~_{<(1Jz3i@<-BOhMpkcWFQR1;E$_9%;shlKxb*echlKsJu0oYg??Hmz~}ySWA^|>0YyI-YexConzRmRc2m5t(VI&eq(@zu z(Jb=ItD)1a=lN6te)>KAyEBv4H!kZxYdl;C$PwZ%f4?$*B0t%)QYSw0*|S^wEA=n0 zc?q}dEQ+D_R~rZH$ipH;-VX0az;z-Qh@_g>HG$$Jyw2pYsiusg;4lmjCqHjH`Pp-6 zl|Aa8NHrm1GbSw>e*^KId;j@fpM6z8Za3CZhwX1i+URO^R*1lS?QVod{Y&89g(P!d zpBn|%lmchjkZ(*fH`@7677Em60ZR7KpY)|yWj`_&jFood2rR%`EPhMjfgzsl>5thz zMCiyQRbCrWo(p)aXN%K}eRgtxv20YKe0rzx2LficnreQAPdmpj#dmKUJ@e>=0#|~V zxlO5C4^4U<(${o+~*=Cb~$B)if@OvP(G(=}2cqqumfGW;mUoIzpzB zsTddgy%DDy&r5DJ2$R?q6fP#r{sb&u`;UG(rg9N|#3y#LkIcZq!;=@$Z@}E>cReyb z)hxo;zIV{BDxiQ`U{QbpHkGEw8!}9k5qe}jDc^stYiYgGOwGV8N@4x1fI!j9 zxb~cK3V!5HmM+JzccVX9rGj#}JsGEzl-J`wua0)UhW8U^xpA?baM_TtfE*+Cg1|@} za4+JM^G<*8`)#E#F?w^UCV1T`HZXJ~3*ld|>V5LW;8%}sj~=OmnRM(=pcnk7&Aomg zyBY;hcFwZzgbY8wMLGybRj)iXOf*cop&{MplP7&4Y;S8*Aau^Zm3puToaJ)sw2pB*uTXYDi=h z2<@zaPdT@LdT^-^0YD7EXz%y@#PXNf$b|WKPYi?gQ*Q;2eS1P>o3DO+Y@Z-R-UfTl z((c6XIj64mCL}58kk_Wf1#5Muc6Otn-F>zn!SY8($E0IkEz2S${f@>L->GJXW2cOR z?*j(Gbn?>G6t?p)WJz#h2`DEc4YVkLU&zQ0q0~PS|nql6W zLkhV^&u0>SyUXy4iLxi?JxoT8R>jWJnlDEizbD|f<7j?4_l=;HF+3g*oNcl}cEI9* z!s_3#%Eknown+_g)2R)UMb>=q;4cuFIU%6ox3iXBy7STe`{AvH@l4i~nBR7S_b*3q zGu~j^dh}~dQH$<~K*Jq~=)e9(N6W$|ME>~Fz^?Ts=@V_s8;J9ih$v83?eD(<#t;Pj z`_RoEOG9Hm#Q@Ow!#Tt)B&f#nrcn}i0C!fl?7U2>P-(DP;gcLbDy2s zxEt>S-#{F2PXz>7jTOc|wodBP2LY=JbD&33`;ng__kAkwYBuCtu)@&-Ti_vI^&p z)Kpl=NWh>6a9mxwvt!G0(q&P6hk!`r+m~K_JIazN?}0ga4!Rh5gb3obMsMo z;POasfo)YAIMMH=;8ehSqLucv1j5Yf!o!$#*x9%PKiΥS__NoWv1xKAsoaU9OH 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 literal 0 HcmV?d00001 diff --git a/packages/component/src/Activity/StackedLayout.tsx b/packages/component/src/Activity/StackedLayout.tsx index 377fcea483..8d9eb45cc1 100644 --- a/packages/component/src/Activity/StackedLayout.tsx +++ b/packages/component/src/Activity/StackedLayout.tsx @@ -21,7 +21,7 @@ import getFirstBaseOfSoftwareSourceCode from '../Utils/orgSchema/getFirstBaseOfS import textFormatToContentType from '../Utils/textFormatToContentType'; import useLocalizeActivityGreetingAlt from '../hooks/internal/useLocalizeActivityGreetingAlt.js'; import useLocalizeProgrammingLanguage from '../hooks/internal/useLocalizeProgrammingLanguage'; -import { useGetLogicalGroupKey } from '../providers/ActivityLogicalGrouping'; +import { useLogicalGroupKey } from '../providers/ActivityLogicalGrouping'; import AttachmentRow from './AttachmentRow'; import Bubble from './Bubble'; import CodeBlockContent from './CodeBlockContent'; @@ -144,8 +144,8 @@ const StackedLayout = ({ const nubSize = fromUser ? bubbleFromUserNubSize : bubbleNubSize; const otherInitials = fromUser ? botInitials : userInitials; const otherNubSize = fromUser ? bubbleNubSize : bubbleFromUserNubSize; - const activityKey = useGetKeyByActivity()(activity); - const isInGroup = !!useGetLogicalGroupKey()(activityKey); + const [logicalGroupKey] = useLogicalGroupKey(useGetKeyByActivity()(activity)); + const isInGroup = !!logicalGroupKey; const hasAvatar = (initials || typeof initials === 'string') && !isInGroup; const hasOtherAvatar = otherInitials || typeof otherInitials === 'string'; diff --git a/packages/component/src/Middleware/ActivityGrouping/ui/PartGrouping/private/PartGroupingActivity.tsx b/packages/component/src/Middleware/ActivityGrouping/ui/PartGrouping/private/PartGroupingActivity.tsx index 4b221a11c4..4e59f1c54d 100644 --- a/packages/component/src/Middleware/ActivityGrouping/ui/PartGrouping/private/PartGroupingActivity.tsx +++ b/packages/component/src/Middleware/ActivityGrouping/ui/PartGrouping/private/PartGroupingActivity.tsx @@ -13,7 +13,7 @@ import StackedLayoutStatus from '../../../../../Activity/StackedLayoutStatus'; import useActivityElementMapRef from '../../../../../providers/ChatHistoryDOM/useActivityElementRef'; import useActiveDescendantId from '../../../../../providers/TranscriptFocus/useActiveDescendantId'; import useFocusByGroupKey from '../../../../../providers/TranscriptFocus/useFocusByGroupKey'; -import useGetGroupDescendantIdByActivityKey from '../../../../../providers/TranscriptFocus/useGetGroupDescendantIdByActivityKey'; +import useGetGroupDescendantIdByGroupKey from '../../../../../providers/TranscriptFocus/useGetGroupDescendantIdByGroupKey'; import FocusTrap from '../../../../../Transcript/FocusTrap'; import useRenderActivityProps from '../../../../../Transcript/hooks/useRenderActivityProps'; import { @@ -50,7 +50,6 @@ type PartGroupingActivityProps = InferOutput(value => is(object({}), value)), children: optional(reactNode()), className: optional(string()), groupKey: string() @@ -64,15 +63,12 @@ type FocusablePartGroupingActivityProps = InferOutput void>( (withFocus?: boolean) => focusByGroupKey(groupKey, withFocus), @@ -220,11 +216,7 @@ function PartGroupingActivity(props: PartGroupingActivityProps) { ); return ( - + `part-${random().toString(36).slice(2, 11)}`); + const [partGroupKey] = useState(() => `part-${random().toString(36).slice(2, 11)}`); const addLogicalGrouping = useAddLogicalGrouping(); const removeLogicalGrouping = useRemoveLogicalGrouping(); @@ -20,19 +19,17 @@ function usePartGroupingLogicalGroup({ activityKeys, isCollapsed }: UsePartGroup const getGroupState = useCallback(() => ({ isCollapsed: isCollapsedRef.current }), [isCollapsedRef]); - useMemo( - () => - addLogicalGrouping({ - key: partKeyRef.current, - activityKeys: Array.from(activityKeys), - getGroupState - }), - [activityKeys, addLogicalGrouping, getGroupState, partKeyRef] - ); - - const removeLogicalGroupingRef = useRefFrom(removeLogicalGrouping); - - useEffect(() => () => removeLogicalGroupingRef.current(partKeyRef.current), [partKeyRef, removeLogicalGroupingRef]); + // Adds the group in an effect, not while rendering: Strict Mode runs the cleanup below, then runs this effect again. + // A layout effect, so activities using `useLogicalGroupKey()` re-render before the browser paints. + useLayoutEffect(() => { + addLogicalGrouping({ + activityKeys: Array.from(activityKeys), + getGroupState, + key: partGroupKey + }); + }, [activityKeys, addLogicalGrouping, getGroupState, partGroupKey]); + + useLayoutEffect(() => () => removeLogicalGrouping(partGroupKey), [partGroupKey, removeLogicalGrouping]); return partGroupKey; } diff --git a/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts b/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts index 2fdb1b78c8..fa776fb9bb 100644 --- a/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts +++ b/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts @@ -21,9 +21,12 @@ const ActivityLogicalGroupingComposer = (props: ActivityLogicalGroupingComposerP const { children } = validateProps(activityLogicalGroupingComposerPropsSchema, props); const logicalGroupingsRef = useRef>(new Map()); - // A ref, not a state: part grouping adds itself while rendering, and its activities read their group while rendering. const activityToGroupMapRef = useRef>(new Map()); + const listenersRef = useRef void>>(new Set()); + const notify = useCallback(() => listenersRef.current.forEach(listener => listener()), [listenersRef]); + + // Adding and removing notify `useLogicalGroupKey()`, which re-renders: call them from effects, never while rendering. const addLogicalGrouping = useCallback( (grouping: LogicalGrouping) => { const prevGroupingKeys = logicalGroupingsRef.current.get(grouping.key)?.activityKeys ?? []; @@ -39,16 +42,20 @@ const ActivityLogicalGroupingComposer = (props: ActivityLogicalGroupingComposerP Array.from(toAddSet).map(activityKey => [activityKey, grouping.key] as const) ) ); + + notify(); }, - [activityToGroupMapRef] + [activityToGroupMapRef, logicalGroupingsRef, notify] ); const removeLogicalGrouping = useCallback( (key: string) => { logicalGroupingsRef.current.delete(key); activityToGroupMapRef.current = new Map([...activityToGroupMapRef.current].filter(([, value]) => value !== key)); + + notify(); }, - [activityToGroupMapRef] + [activityToGroupMapRef, logicalGroupingsRef, notify] ); const getLogicalGroupKey = useCallback( @@ -56,6 +63,17 @@ const ActivityLogicalGroupingComposer = (props: ActivityLogicalGroupingComposerP [activityToGroupMapRef] ); + const subscribeLogicalGrouping = useCallback( + (listener: () => void): (() => void) => { + listenersRef.current.add(listener); + + return () => { + listenersRef.current.delete(listener); + }; + }, + [listenersRef] + ); + const getGroupState = useCallback( (groupKey: string): GroupState | undefined => { const group = logicalGroupingsRef.current.get(groupKey); @@ -77,14 +95,23 @@ const ActivityLogicalGroupingComposer = (props: ActivityLogicalGroupingComposerP ); const contextValue: ActivityLogicalGroupingContextType = useMemo( - () => ({ + () => + Object.freeze({ + addLogicalGrouping, + getGroupBoundaries, + getGroupState, + getLogicalGroupKey, + removeLogicalGrouping, + subscribeLogicalGrouping + }), + [ addLogicalGrouping, - getLogicalGroupKey, - getGroupState, getGroupBoundaries, - removeLogicalGrouping - }), - [addLogicalGrouping, getLogicalGroupKey, getGroupState, getGroupBoundaries, removeLogicalGrouping] + getGroupState, + getLogicalGroupKey, + removeLogicalGrouping, + subscribeLogicalGrouping + ] ); return createElement(ActivityLogicalGroupingContext.Provider, { value: contextValue }, children); diff --git a/packages/component/src/providers/ActivityLogicalGrouping/index.ts b/packages/component/src/providers/ActivityLogicalGrouping/index.ts index 50b2650c78..5db7ba87c1 100644 --- a/packages/component/src/providers/ActivityLogicalGrouping/index.ts +++ b/packages/component/src/providers/ActivityLogicalGrouping/index.ts @@ -3,5 +3,6 @@ export { default as useAddLogicalGrouping } from './useAddLogicalGrouping'; export { default as useRemoveLogicalGrouping } from './useRemoveLogicalGrouping'; export { default as useGetLogicalGroupKey } from './useGetLogicalGroupKey'; export { default as useGetGroupState } from './useGetGroupState'; +export { default as useLogicalGroupKey } from './useLogicalGroupKey'; export type { ActivityLogicalGroupingComposerProps } from './ActivityLogicalGroupingComposer'; export type { LogicalGrouping, GroupState } from './private/ActivityLogicalGroupingContext'; diff --git a/packages/component/src/providers/ActivityLogicalGrouping/private/ActivityLogicalGroupingContext.ts b/packages/component/src/providers/ActivityLogicalGrouping/private/ActivityLogicalGroupingContext.ts index e0f5397b41..eb523b5f0e 100644 --- a/packages/component/src/providers/ActivityLogicalGrouping/private/ActivityLogicalGroupingContext.ts +++ b/packages/component/src/providers/ActivityLogicalGrouping/private/ActivityLogicalGroupingContext.ts @@ -16,6 +16,7 @@ type ActivityLogicalGroupingContextType = Readonly<{ getGroupState: (groupKey: string) => GroupState | undefined; getLogicalGroupKey: (activityKey: string) => string | undefined; removeLogicalGrouping: (key: string) => void; + subscribeLogicalGrouping: (listener: () => void) => () => void; }>; const { contextComponentType, useContext } = createContextAndHook( diff --git a/packages/component/src/providers/ActivityLogicalGrouping/useLogicalGroupKey.ts b/packages/component/src/providers/ActivityLogicalGrouping/useLogicalGroupKey.ts new file mode 100644 index 0000000000..f7cde7e87b --- /dev/null +++ b/packages/component/src/providers/ActivityLogicalGrouping/useLogicalGroupKey.ts @@ -0,0 +1,45 @@ +import { useLayoutEffect, useMemo, useReducer, useRef } from 'react'; + +import { useActivityLogicalGroupingContext } from './private/ActivityLogicalGroupingContext'; + +function increment(value: number): number { + return value + 1; +} + +/** + * Returns the key of the logical group the activity belongs to, or `undefined` if it is not part of any group. + * + * The component re-renders only when the group key of this activity changes. + * Event handlers, which read the latest value at call time, should use `useGetLogicalGroupKey()` instead. + */ +export default function useLogicalGroupKey(activityKey: string): readonly [string | undefined] { + const { getLogicalGroupKey, subscribeLogicalGrouping } = useActivityLogicalGroupingContext(); + const [, forceRender] = useReducer(increment, 0); + + const logicalGroupKey = getLogicalGroupKey(activityKey); + const logicalGroupKeyRef = useRef(logicalGroupKey); + + useLayoutEffect(() => { + logicalGroupKeyRef.current = logicalGroupKey; + }); + + useLayoutEffect(() => { + const handleChange = () => { + const nextLogicalGroupKey = getLogicalGroupKey(activityKey); + + if (nextLogicalGroupKey !== logicalGroupKeyRef.current) { + // Remembers the new value now, so another change before the re-render does not re-render again. + logicalGroupKeyRef.current = nextLogicalGroupKey; + + forceRender(); + } + }; + + // Catches changes between render and subscription. + handleChange(); + + return subscribeLogicalGrouping(handleChange); + }, [activityKey, forceRender, getLogicalGroupKey, logicalGroupKeyRef, subscribeLogicalGrouping]); + + return useMemo(() => Object.freeze([logicalGroupKey] as const), [logicalGroupKey]); +} diff --git a/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.tsx b/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.tsx index b6dbce4936..c5ffeb1d30 100644 --- a/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.tsx +++ b/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.tsx @@ -47,11 +47,6 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp [prefix] ); - const getGroupDescendantIdByActivityKey: (activityKey?: string) => string | undefined = useCallback( - (activityKey?: string) => activityKey && getGroupDescendantIdByGroupKey(getGroupKeyByActivityKey(activityKey)), - [getGroupDescendantIdByGroupKey, getGroupKeyByActivityKey] - ); - const renderingActivityKeysRef = useValueRef(renderingActivityKeys); // While the transcript or any descendants are not focused, if the transcript is updated, reset the user-selected active descendant. @@ -404,7 +399,7 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp focusedGroupKeyState: Object.freeze([rawFocusedActivityGroupKey]) as readonly [string], focusRelativeActivity, getDescendantIdByActivityKey, - getGroupDescendantIdByActivityKey + getGroupDescendantIdByGroupKey }), [ activeDescendantId, @@ -414,7 +409,7 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp focusedActivityKey, focusRelativeActivity, getDescendantIdByActivityKey, - getGroupDescendantIdByActivityKey, + getGroupDescendantIdByGroupKey, rawFocusedActivityGroupKey, rawFocusedActivityKey ] diff --git a/packages/component/src/providers/TranscriptFocus/private/Context.ts b/packages/component/src/providers/TranscriptFocus/private/Context.ts index 7ae4f5dc40..e5633dcfca 100644 --- a/packages/component/src/providers/TranscriptFocus/private/Context.ts +++ b/packages/component/src/providers/TranscriptFocus/private/Context.ts @@ -10,7 +10,7 @@ type TranscriptFocusContextType = { focusedGroupKeyState: readonly [string]; focusRelativeActivity: (delta: number) => void; getDescendantIdByActivityKey: (activityKey?: string) => string | undefined; - getGroupDescendantIdByActivityKey: (activityKey?: string) => string | undefined; + getGroupDescendantIdByGroupKey: (groupKey?: string) => string | undefined; }; const TranscriptFocusContext = createContext(undefined); diff --git a/packages/component/src/providers/TranscriptFocus/useGetGroupDescendantIdByActivityKey.ts b/packages/component/src/providers/TranscriptFocus/useGetGroupDescendantIdByActivityKey.ts deleted file mode 100644 index 452249399c..0000000000 --- a/packages/component/src/providers/TranscriptFocus/useGetGroupDescendantIdByActivityKey.ts +++ /dev/null @@ -1,5 +0,0 @@ -import useTranscriptFocusContext from './private/useContext'; - -export default function useGetGroupDescendantIdByActivityKey(): (activityKey?: string) => string | undefined { - return useTranscriptFocusContext().getGroupDescendantIdByActivityKey; -} diff --git a/packages/component/src/providers/TranscriptFocus/useGetGroupDescendantIdByGroupKey.ts b/packages/component/src/providers/TranscriptFocus/useGetGroupDescendantIdByGroupKey.ts new file mode 100644 index 0000000000..73bfa74da6 --- /dev/null +++ b/packages/component/src/providers/TranscriptFocus/useGetGroupDescendantIdByGroupKey.ts @@ -0,0 +1,5 @@ +import useTranscriptFocusContext from './private/useContext'; + +export default function useGetGroupDescendantIdByGroupKey(): (groupKey?: string) => string | undefined { + return useTranscriptFocusContext().getGroupDescendantIdByGroupKey; +} From 46cf2707478b54303388a2bc72e640f1620d92f8 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 22 Oct 2026 15:14:49 -0700 Subject: [PATCH 07/18] Remove an activity from its logical group when it leaves the group The filter compared group keys with activity keys, so no activity was ever removed. --- .../ActivityLogicalGroupingComposer.ts | 4 +- .../useLogicalGroupKey.spec.tsx | 77 +++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 packages/component/src/providers/ActivityLogicalGrouping/useLogicalGroupKey.spec.tsx diff --git a/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts b/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts index fa776fb9bb..c24b376563 100644 --- a/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts +++ b/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts @@ -38,7 +38,9 @@ const ActivityLogicalGroupingComposer = (props: ActivityLogicalGroupingComposerP activityToGroupMapRef.current = new Map( [].concat( - Array.from(activityToGroupMapRef.current).filter(([, value]) => !!value && !toRemoveSet.has(value)), + Array.from(activityToGroupMapRef.current).filter( + ([activityKey, groupKey]) => !!groupKey && (groupKey !== grouping.key || !toRemoveSet.has(activityKey)) + ), Array.from(toAddSet).map(activityKey => [activityKey, grouping.key] as const) ) ); diff --git a/packages/component/src/providers/ActivityLogicalGrouping/useLogicalGroupKey.spec.tsx b/packages/component/src/providers/ActivityLogicalGrouping/useLogicalGroupKey.spec.tsx new file mode 100644 index 0000000000..4ef22e7e7f --- /dev/null +++ b/packages/component/src/providers/ActivityLogicalGrouping/useLogicalGroupKey.spec.tsx @@ -0,0 +1,77 @@ +/** @jest-environment @happy-dom/jest-environment */ + +import 'core-js/features/set/index.js'; + +import * as JestGlobals from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; +import React, { memo, useLayoutEffect } from 'react'; +import { render } from 'react-dom'; +import { act } from 'react-dom/test-utils'; + +import ActivityLogicalGroupingComposer from './ActivityLogicalGroupingComposer'; +import useAddLogicalGrouping from './useAddLogicalGrouping'; +import useLogicalGroupKey from './useLogicalGroupKey'; + +type Renders = { readonly a: (string | undefined)[]; readonly b: (string | undefined)[] }; + +function Group({ activityKeys }: { readonly activityKeys: string[] }) { + const addLogicalGrouping = useAddLogicalGrouping(); + + useLayoutEffect(() => { + addLogicalGrouping({ activityKeys, key: 'g' }); + }, [activityKeys, addLogicalGrouping]); + + return null; +} + +function Row({ activityKey, renders }: { readonly activityKey: 'a' | 'b'; readonly renders: Renders }) { + // eslint-disable-next-line security/detect-object-injection + renders[activityKey].push(useLogicalGroupKey(activityKey)[0]); + + return null; +} + +// Memoized like activities in the transcript, so only its subscription can re-render it. +const MemoizedRow = memo(Row); + +function renderGroup(container: HTMLElement, activityKeys: string[], renders: Renders) { + act(() => { + render( + + + + + , + container + ); + }); +} + +scenario( + 'useLogicalGroupKey', + bdd => { + bdd + .given('an empty transcript', () => ({ container: document.createElement('div'), renders: { a: [], b: [] } })) + .when('activities "a" and "b" are added to group "g"', ({ container, renders }) => + renderGroup(container, ['a', 'b'], renders) + ) + .then('both activities should be in group "g"', ({ renders }) => { + // Rendered before the group is added in a layout effect, then re-rendered with the group before paint. + expect(renders.a).toStrictEqual([undefined, 'g']); + expect(renders.b).toStrictEqual([undefined, 'g']); + }) + .when('activity "b" leaves the group', ({ container, renders }) => { + renders.a.splice(0); + renders.b.splice(0); + + renderGroup(container, ['a'], renders); + }) + .then('activity "a" should not re-render, its group did not change', ({ renders }) => + expect(renders.a).toStrictEqual([]) + ) + .and('activity "b" should re-render once, without a group', ({ renders }) => + expect(renders.b).toStrictEqual([undefined]) + ); + }, + JestGlobals +); From a10e12339692e6af491cc3c5365181aaf8b3f9df Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 22 Oct 2026 16:08:40 -0700 Subject: [PATCH 08/18] Add the changelog fragment --- .changelog/5966-fix-logical-group-key-hook.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .changelog/5966-fix-logical-group-key-hook.md diff --git a/.changelog/5966-fix-logical-group-key-hook.md b/.changelog/5966-fix-logical-group-key-hook.md new file mode 100644 index 0000000000..767e71d43a --- /dev/null +++ b/.changelog/5966-fix-logical-group-key-hook.md @@ -0,0 +1,3 @@ +### Fixed + +- Fixed grouped messages showing avatars and breaking keyboard navigation in React Strict Mode, in PR [#5966](https://github.com/microsoft/BotFramework-WebChat/pull/5966), by [@OEvgeny](https://github.com/OEvgeny) From f1f25783c369e7109e1aaba634d504a5d1a09995 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 22 Oct 2026 16:53:44 -0700 Subject: [PATCH 09/18] Subscribe by key: useGroupState(), useGroupDescendantIdByGroupKey() - 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) --- .../private/PartGroupingActivity.tsx | 4 +- .../private/usePartGroupingLogicalGroup.ts | 17 ++- .../createKeyedSubscription.spec.ts | 38 ++++++ .../createKeyedSubscription.ts | 35 ++++++ .../useKeyedSubscriptionValue.spec.tsx | 114 ++++++++++++++++++ .../useKeyedSubscriptionValue.ts | 47 ++++++++ .../ActivityLogicalGroupingComposer.spec.tsx | 93 ++++++++++++++ .../ActivityLogicalGroupingComposer.ts | 75 ++++++------ .../ActivityLogicalGrouping/index.ts | 5 +- .../private/ActivityLogicalGroupingContext.ts | 20 +-- .../private/useGetGroupState.ts | 11 ++ .../private/useGetLogicalGroupBoundaries.ts | 10 ++ .../private/useGetLogicalGroupKey.ts | 11 ++ .../useGetGroupState.ts | 5 - .../useGetLogicalGroupBoundaries.ts | 5 - .../useGetLogicalGroupKey.ts | 5 - .../useGroupState.spec.tsx | 100 +++++++++++++++ .../ActivityLogicalGrouping/useGroupState.ts | 13 ++ .../useLogicalGroupKey.ts | 38 +----- ...ranscriptFocusComposer.groupState.spec.tsx | 94 +++++++++++++++ .../TranscriptFocusComposer.spec.tsx | 2 +- .../TranscriptFocusComposer.tsx | 7 +- .../useGetGroupDescendantIdByGroupKey.ts | 5 - .../useGroupDescendantIdByGroupKey.ts | 17 +++ 24 files changed, 656 insertions(+), 115 deletions(-) create mode 100644 packages/component/src/hooks/internal/keyedSubscription/createKeyedSubscription.spec.ts create mode 100644 packages/component/src/hooks/internal/keyedSubscription/createKeyedSubscription.ts create mode 100644 packages/component/src/hooks/internal/keyedSubscription/useKeyedSubscriptionValue.spec.tsx create mode 100644 packages/component/src/hooks/internal/keyedSubscription/useKeyedSubscriptionValue.ts create mode 100644 packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.spec.tsx create mode 100644 packages/component/src/providers/ActivityLogicalGrouping/private/useGetGroupState.ts create mode 100644 packages/component/src/providers/ActivityLogicalGrouping/private/useGetLogicalGroupBoundaries.ts create mode 100644 packages/component/src/providers/ActivityLogicalGrouping/private/useGetLogicalGroupKey.ts delete mode 100644 packages/component/src/providers/ActivityLogicalGrouping/useGetGroupState.ts delete mode 100644 packages/component/src/providers/ActivityLogicalGrouping/useGetLogicalGroupBoundaries.ts delete mode 100644 packages/component/src/providers/ActivityLogicalGrouping/useGetLogicalGroupKey.ts create mode 100644 packages/component/src/providers/ActivityLogicalGrouping/useGroupState.spec.tsx create mode 100644 packages/component/src/providers/ActivityLogicalGrouping/useGroupState.ts create mode 100644 packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.groupState.spec.tsx delete mode 100644 packages/component/src/providers/TranscriptFocus/useGetGroupDescendantIdByGroupKey.ts create mode 100644 packages/component/src/providers/TranscriptFocus/useGroupDescendantIdByGroupKey.ts diff --git a/packages/component/src/Middleware/ActivityGrouping/ui/PartGrouping/private/PartGroupingActivity.tsx b/packages/component/src/Middleware/ActivityGrouping/ui/PartGrouping/private/PartGroupingActivity.tsx index 4e59f1c54d..f0fde958d4 100644 --- a/packages/component/src/Middleware/ActivityGrouping/ui/PartGrouping/private/PartGroupingActivity.tsx +++ b/packages/component/src/Middleware/ActivityGrouping/ui/PartGrouping/private/PartGroupingActivity.tsx @@ -13,7 +13,7 @@ import StackedLayoutStatus from '../../../../../Activity/StackedLayoutStatus'; import useActivityElementMapRef from '../../../../../providers/ChatHistoryDOM/useActivityElementRef'; import useActiveDescendantId from '../../../../../providers/TranscriptFocus/useActiveDescendantId'; import useFocusByGroupKey from '../../../../../providers/TranscriptFocus/useFocusByGroupKey'; -import useGetGroupDescendantIdByGroupKey from '../../../../../providers/TranscriptFocus/useGetGroupDescendantIdByGroupKey'; +import useGroupDescendantIdByGroupKey from '../../../../../providers/TranscriptFocus/useGroupDescendantIdByGroupKey'; import FocusTrap from '../../../../../Transcript/FocusTrap'; import useRenderActivityProps from '../../../../../Transcript/hooks/useRenderActivityProps'; import { @@ -68,7 +68,7 @@ const FocusablePartGroupingActivity = memo(function FocusablePartGroupingActivit const [activeDescendantId] = useActiveDescendantId(); const focusByGroupKey = useFocusByGroupKey(); - const groupingActivityDescendantId = useGetGroupDescendantIdByGroupKey()(groupKey); + const [groupingActivityDescendantId] = useGroupDescendantIdByGroupKey(groupKey); const focusSelf = useCallback<(withFocus?: boolean) => void>( (withFocus?: boolean) => focusByGroupKey(groupKey, withFocus), diff --git a/packages/component/src/Middleware/ActivityGrouping/ui/PartGrouping/private/usePartGroupingLogicalGroup.ts b/packages/component/src/Middleware/ActivityGrouping/ui/PartGrouping/private/usePartGroupingLogicalGroup.ts index 7e83a171dc..c3e604e172 100644 --- a/packages/component/src/Middleware/ActivityGrouping/ui/PartGrouping/private/usePartGroupingLogicalGroup.ts +++ b/packages/component/src/Middleware/ActivityGrouping/ui/PartGrouping/private/usePartGroupingLogicalGroup.ts @@ -1,6 +1,5 @@ import random from 'math-random'; -import { useCallback, useLayoutEffect, useState } from 'react'; -import { useRefFrom } from 'use-ref-from'; +import { useLayoutEffect, useMemo, useState } from 'react'; import { useAddLogicalGrouping, useRemoveLogicalGrouping } from '../../../../../providers/ActivityLogicalGrouping'; @@ -15,19 +14,19 @@ function usePartGroupingLogicalGroup({ activityKeys, isCollapsed }: UsePartGroup const addLogicalGrouping = useAddLogicalGrouping(); const removeLogicalGrouping = useRemoveLogicalGrouping(); - const isCollapsedRef = useRefFrom(isCollapsed); - - const getGroupState = useCallback(() => ({ isCollapsed: isCollapsedRef.current }), [isCollapsedRef]); + // Same object until collapsed or expanded, so `useGroupState()` re-renders only then. + const groupState = useMemo(() => Object.freeze({ isCollapsed }), [isCollapsed]); // Adds the group in an effect, not while rendering: Strict Mode runs the cleanup below, then runs this effect again. - // A layout effect, so activities using `useLogicalGroupKey()` re-render before the browser paints. + // A layout effect, so activities using `useLogicalGroupKey()` or `useGroupState()` re-render before the browser paints. + // Collapsing or expanding the group updates it here, which notifies `useGroupState()`. useLayoutEffect(() => { addLogicalGrouping({ - activityKeys: Array.from(activityKeys), - getGroupState, + activityKeys: Object.freeze(Array.from(activityKeys)), + groupState, key: partGroupKey }); - }, [activityKeys, addLogicalGrouping, getGroupState, partGroupKey]); + }, [activityKeys, addLogicalGrouping, groupState, partGroupKey]); useLayoutEffect(() => () => removeLogicalGrouping(partGroupKey), [partGroupKey, removeLogicalGrouping]); diff --git a/packages/component/src/hooks/internal/keyedSubscription/createKeyedSubscription.spec.ts b/packages/component/src/hooks/internal/keyedSubscription/createKeyedSubscription.spec.ts new file mode 100644 index 0000000000..11ffdc0b5d --- /dev/null +++ b/packages/component/src/hooks/internal/keyedSubscription/createKeyedSubscription.spec.ts @@ -0,0 +1,38 @@ +import * as JestGlobals from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; + +import createKeyedSubscription from './createKeyedSubscription'; + +scenario( + 'createKeyedSubscription', + bdd => { + bdd + .given('listeners subscribed to "a" and "b"', () => { + const { notify, subscribe } = createKeyedSubscription(); + const listenerA = JestGlobals.jest.fn(); + const listenerB = JestGlobals.jest.fn(); + + return { + listenerA, + listenerB, + notify, + unsubscribeA: subscribe('a', listenerA), + unsubscribeB: subscribe('b', listenerB) + }; + }) + .when('"a" is notified twice in one call', ({ notify }) => notify(['a', 'a'])) + .then('only the listener of "a" should be called, once', ({ listenerA, listenerB }) => { + expect(listenerA).toHaveBeenCalledTimes(1); + expect(listenerB).not.toHaveBeenCalled(); + }) + .when('the listener of "a" unsubscribes and both keys are notified', ({ notify, unsubscribeA }) => { + unsubscribeA(); + notify(['a', 'b']); + }) + .then('only the listener of "b" should be called', ({ listenerA, listenerB }) => { + expect(listenerA).toHaveBeenCalledTimes(1); // From the first notification. + expect(listenerB).toHaveBeenCalledTimes(1); + }); + }, + JestGlobals +); diff --git a/packages/component/src/hooks/internal/keyedSubscription/createKeyedSubscription.ts b/packages/component/src/hooks/internal/keyedSubscription/createKeyedSubscription.ts new file mode 100644 index 0000000000..62b89ab0f3 --- /dev/null +++ b/packages/component/src/hooks/internal/keyedSubscription/createKeyedSubscription.ts @@ -0,0 +1,35 @@ +type KeyedSubscription = { + /** Calls the listeners of each key. Call it after commit (e.g. from an effect), as listeners may re-render. */ + readonly notify: (keys: Iterable) => void; + /** Calls the listener when the key is notified, until unsubscribed. */ + readonly subscribe: (key: K, listener: () => void) => () => void; +}; + +/** Creates a store of listeners by key, so a change notifies only the listeners interested in it. */ +export default function createKeyedSubscription(): KeyedSubscription { + const listenersByKey = new Map void>>(); + + return Object.freeze({ + notify: (keys: Iterable) => { + for (const key of new Set(keys)) { + // Copies the listeners: a listener may unsubscribe while being notified. + for (const listener of Array.from(listenersByKey.get(key) ?? [])) { + listener(); + } + } + }, + subscribe: (key: K, listener: () => void) => { + const listeners = listenersByKey.get(key) ?? new Set(); + + listeners.add(listener); + listenersByKey.set(key, listeners); + + return () => { + listeners.delete(listener); + listeners.size || listenersByKey.delete(key); + }; + } + }); +} + +export { type KeyedSubscription }; diff --git a/packages/component/src/hooks/internal/keyedSubscription/useKeyedSubscriptionValue.spec.tsx b/packages/component/src/hooks/internal/keyedSubscription/useKeyedSubscriptionValue.spec.tsx new file mode 100644 index 0000000000..0c58fbe9b7 --- /dev/null +++ b/packages/component/src/hooks/internal/keyedSubscription/useKeyedSubscriptionValue.spec.tsx @@ -0,0 +1,114 @@ +/** @jest-environment @happy-dom/jest-environment */ + +import * as JestGlobals from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; +import React, { Fragment, memo, useLayoutEffect } from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { act } from 'react-dom/test-utils'; + +import createKeyedSubscription, { type KeyedSubscription } from './createKeyedSubscription'; +import useKeyedSubscriptionValue from './useKeyedSubscriptionValue'; + +type Store = { + readonly getValue: (key: string) => string | undefined; + readonly renders: { readonly a: (string | undefined)[]; readonly b: (string | undefined)[] }; + readonly subscription: KeyedSubscription; + readonly values: Map; +}; + +function createStore(): Store { + const values = new Map(); + + return { + getValue: key => values.get(key), + renders: { a: [], b: [] }, + subscription: createKeyedSubscription(), + values + }; +} + +function Reader({ store, valueKey }: { readonly store: Store; readonly valueKey: 'a' | 'b' }) { + // eslint-disable-next-line security/detect-object-injection + store.renders[valueKey].push(useKeyedSubscriptionValue(store.subscription.subscribe, valueKey, store.getValue)[0]); + + return null; +} + +// Memoized like activities in the transcript, so only its subscription can re-render it. +const MemoizedReader = memo(Reader); + +// Placed before ``: its layout effect runs after `` rendered and before `` subscribes. +// Changes "a" without notifying, so only the re-check after subscribing can see it. +function SetValueBeforeSubscribe({ store }: { readonly store: Store }) { + useLayoutEffect(() => { + store.values.set('a', 'missed'); + }, [store]); + + return null; +} + +scenario( + 'useKeyedSubscriptionValue', + bdd => { + bdd + .given('readers of "a" and "b"', () => { + const container = document.createElement('div'); + const store = createStore(); + + act(() => { + render( + + + + , + container + ); + }); + + return { container, store }; + }) + .when('"a" changes and is notified', ({ store }) => { + store.values.set('a', '1'); + + act(() => store.subscription.notify(['a'])); + }) + .then('only the reader of "a" should re-render', ({ store }) => { + expect(store.renders.a).toStrictEqual([undefined, '1']); + expect(store.renders.b).toStrictEqual([undefined]); + }) + .when('"b" is notified without a change', ({ store }) => { + act(() => store.subscription.notify(['b'])); + }) + .then('the reader of "b" should not re-render', ({ store }) => expect(store.renders.b).toStrictEqual([undefined])) + .when('the readers unmount and "a" changes and is notified', ({ container, store }) => { + act(() => { + unmountComponentAtNode(container); + }); + + store.values.set('a', '2'); + + act(() => store.subscription.notify(['a'])); + }) + .then('the reader of "a" should not render again, it unsubscribed', ({ store }) => + expect(store.renders.a).toStrictEqual([undefined, '1']) + ); + + bdd + .given('a store', () => ({ container: document.createElement('div'), store: createStore() })) + .when('"a" changes after the reader rendered, before it subscribed', ({ container, store }) => { + act(() => { + render( + + + + , + container + ); + }); + }) + .then('the reader should re-render with the missed value', ({ store }) => + expect(store.renders.a).toStrictEqual([undefined, 'missed']) + ); + }, + JestGlobals +); diff --git a/packages/component/src/hooks/internal/keyedSubscription/useKeyedSubscriptionValue.ts b/packages/component/src/hooks/internal/keyedSubscription/useKeyedSubscriptionValue.ts new file mode 100644 index 0000000000..845217e5fc --- /dev/null +++ b/packages/component/src/hooks/internal/keyedSubscription/useKeyedSubscriptionValue.ts @@ -0,0 +1,47 @@ +import { useLayoutEffect, useMemo, useReducer, useRef } from 'react'; + +import { type KeyedSubscription } from './createKeyedSubscription'; + +function increment(value: number): number { + return value + 1; +} + +/** + * Returns `[getValue(key)]` and re-renders when its result changes (compared by identity) after `key` is notified. + * + * `subscribe` and `getValue` should be stable: a new function re-subscribes. + */ +export default function useKeyedSubscriptionValue( + subscribe: KeyedSubscription['subscribe'], + key: K, + getValue: (key: K) => T +): readonly [T] { + const [, forceRender] = useReducer(increment, 0); + + const value = getValue(key); + const valueRef = useRef(value); + + useLayoutEffect(() => { + valueRef.current = value; + }); + + useLayoutEffect(() => { + const handleChange = () => { + const nextValue = getValue(key); + + if (nextValue !== valueRef.current) { + // Remembers the new value now, so another notification before the re-render does not re-render again. + valueRef.current = nextValue; + + forceRender(); + } + }; + + // Catches changes between render and subscription. + handleChange(); + + return subscribe(key, handleChange); + }, [forceRender, getValue, key, subscribe, valueRef]); + + return useMemo(() => Object.freeze([value] as const), [value]); +} diff --git a/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.spec.tsx b/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.spec.tsx new file mode 100644 index 0000000000..2c5846fa24 --- /dev/null +++ b/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.spec.tsx @@ -0,0 +1,93 @@ +/** @jest-environment @happy-dom/jest-environment */ + +import 'core-js/features/set/index.js'; + +import * as JestGlobals from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; +import React from 'react'; +import { render } from 'react-dom'; +import { act } from 'react-dom/test-utils'; + +import ActivityLogicalGroupingComposer from './ActivityLogicalGroupingComposer'; +import { + useActivityLogicalGroupingContext, + type ActivityLogicalGroupingContextType +} from './private/ActivityLogicalGroupingContext'; + +function ContextReader({ contexts }: { readonly contexts: ActivityLogicalGroupingContextType[] }) { + contexts.push(useActivityLogicalGroupingContext()); + + return null; +} + +const COLLAPSED = Object.freeze({ isCollapsed: true }); +const EXPANDED = Object.freeze({ isCollapsed: false }); + +scenario( + 'ActivityLogicalGroupingComposer notifies only the keys that changed', + bdd => { + bdd + .given('listeners for activities "a" and "b" and for group "g"', () => { + const contexts: ActivityLogicalGroupingContextType[] = []; + + act(() => { + render( + + + , + document.createElement('div') + ); + }); + + const listeners = { a: JestGlobals.jest.fn(), b: JestGlobals.jest.fn(), g: JestGlobals.jest.fn() }; + + // The context never changes after the first render. + const [context] = contexts; + + context.subscribeLogicalGroupKey('a', listeners.a); + context.subscribeLogicalGroupKey('b', listeners.b); + context.subscribeGroupState('g', listeners.g); + + return { context, listeners }; + }) + .when('group "g" is added with "a"', ({ context }) => { + context.addLogicalGrouping({ activityKeys: ['a'], groupState: EXPANDED, key: 'g' }); + }) + .then('"a" and "g" should be notified, not "b"', ({ listeners }) => { + expect(listeners.a).toHaveBeenCalledTimes(1); + expect(listeners.b).not.toHaveBeenCalled(); + expect(listeners.g).toHaveBeenCalledTimes(1); + }) + .when('group "g" is updated with "b" added, same state', ({ context, listeners }) => { + Object.values(listeners).forEach(listener => listener.mockClear()); + + context.addLogicalGrouping({ activityKeys: ['a', 'b'], groupState: EXPANDED, key: 'g' }); + }) + .then('only "b" should be notified', ({ listeners }) => { + expect(listeners.a).not.toHaveBeenCalled(); + expect(listeners.b).toHaveBeenCalledTimes(1); + expect(listeners.g).not.toHaveBeenCalled(); + }) + .when('group "g" collapses', ({ context, listeners }) => { + Object.values(listeners).forEach(listener => listener.mockClear()); + + context.addLogicalGrouping({ activityKeys: ['a', 'b'], groupState: COLLAPSED, key: 'g' }); + }) + .then('only "g" should be notified', ({ listeners }) => { + expect(listeners.a).not.toHaveBeenCalled(); + expect(listeners.b).not.toHaveBeenCalled(); + expect(listeners.g).toHaveBeenCalledTimes(1); + }) + .when('group "g" is removed', ({ context, listeners }) => { + Object.values(listeners).forEach(listener => listener.mockClear()); + + context.removeLogicalGrouping('g'); + }) + .then('"a", "b" and "g" should be notified', ({ listeners }) => { + expect(listeners.a).toHaveBeenCalledTimes(1); + expect(listeners.b).toHaveBeenCalledTimes(1); + expect(listeners.g).toHaveBeenCalledTimes(1); + }); + }, + JestGlobals +); diff --git a/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts b/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts index c24b376563..968dde7b4a 100644 --- a/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts +++ b/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts @@ -1,7 +1,8 @@ import { reactNode, validateProps } from '@msinternal/botframework-webchat-react-valibot'; -import { createElement, memo, useCallback, useMemo, useRef } from 'react'; +import { createElement, memo, useCallback, useMemo, useRef, useState } from 'react'; import { object, optional, pipe, readonly, type InferInput } from 'valibot'; +import createKeyedSubscription from '../../hooks/internal/keyedSubscription/createKeyedSubscription'; import ActivityLogicalGroupingContext, { type ActivityLogicalGroupingContextType, type GroupState, @@ -22,42 +23,60 @@ const ActivityLogicalGroupingComposer = (props: ActivityLogicalGroupingComposerP const logicalGroupingsRef = useRef>(new Map()); const activityToGroupMapRef = useRef>(new Map()); - const listenersRef = useRef void>>(new Set()); + const [groupStateSubscription] = useState(() => createKeyedSubscription()); + const [logicalGroupKeySubscription] = useState(() => createKeyedSubscription()); - const notify = useCallback(() => listenersRef.current.forEach(listener => listener()), [listenersRef]); + // Notifies only the activities whose group key changed. + const setActivityToGroupMap = useCallback( + (nextActivityToGroupMap: ReadonlyMap) => { + const { current: prevActivityToGroupMap } = activityToGroupMapRef; - // Adding and removing notify `useLogicalGroupKey()`, which re-renders: call them from effects, never while rendering. + activityToGroupMapRef.current = nextActivityToGroupMap; + + logicalGroupKeySubscription.notify( + Array.from(new Set([...prevActivityToGroupMap.keys(), ...nextActivityToGroupMap.keys()])).filter( + activityKey => prevActivityToGroupMap.get(activityKey) !== nextActivityToGroupMap.get(activityKey) + ) + ); + }, + [activityToGroupMapRef, logicalGroupKeySubscription] + ); + + // Adding and removing notify `useGroupState()` and `useLogicalGroupKey()`, which re-render: call them from effects, never while rendering. const addLogicalGrouping = useCallback( (grouping: LogicalGrouping) => { - const prevGroupingKeys = logicalGroupingsRef.current.get(grouping.key)?.activityKeys ?? []; + const prevGrouping = logicalGroupingsRef.current.get(grouping.key); + const prevGroupingKeys = prevGrouping?.activityKeys ?? []; logicalGroupingsRef.current.set(grouping.key, grouping); const toRemoveSet = new Set(prevGroupingKeys).difference(new Set(grouping.activityKeys)); const toAddSet = new Set(grouping.activityKeys).difference(new Set(prevGroupingKeys)); - activityToGroupMapRef.current = new Map( - [].concat( - Array.from(activityToGroupMapRef.current).filter( - ([activityKey, groupKey]) => !!groupKey && (groupKey !== grouping.key || !toRemoveSet.has(activityKey)) - ), - Array.from(toAddSet).map(activityKey => [activityKey, grouping.key] as const) + setActivityToGroupMap( + new Map( + [].concat( + Array.from(activityToGroupMapRef.current).filter( + ([activityKey, groupKey]) => !!groupKey && (groupKey !== grouping.key || !toRemoveSet.has(activityKey)) + ), + Array.from(toAddSet).map(activityKey => [activityKey, grouping.key] as const) + ) ) ); - notify(); + prevGrouping?.groupState !== grouping.groupState && groupStateSubscription.notify([grouping.key]); }, - [activityToGroupMapRef, logicalGroupingsRef, notify] + [activityToGroupMapRef, groupStateSubscription, logicalGroupingsRef, setActivityToGroupMap] ); const removeLogicalGrouping = useCallback( (key: string) => { logicalGroupingsRef.current.delete(key); - activityToGroupMapRef.current = new Map([...activityToGroupMapRef.current].filter(([, value]) => value !== key)); - notify(); + setActivityToGroupMap(new Map([...activityToGroupMapRef.current].filter(([, value]) => value !== key))); + groupStateSubscription.notify([key]); }, - [activityToGroupMapRef, logicalGroupingsRef, notify] + [activityToGroupMapRef, groupStateSubscription, logicalGroupingsRef, setActivityToGroupMap] ); const getLogicalGroupKey = useCallback( @@ -65,22 +84,8 @@ const ActivityLogicalGroupingComposer = (props: ActivityLogicalGroupingComposerP [activityToGroupMapRef] ); - const subscribeLogicalGrouping = useCallback( - (listener: () => void): (() => void) => { - listenersRef.current.add(listener); - - return () => { - listenersRef.current.delete(listener); - }; - }, - [listenersRef] - ); - const getGroupState = useCallback( - (groupKey: string): GroupState | undefined => { - const group = logicalGroupingsRef.current.get(groupKey); - return group?.getGroupState?.(); - }, + (groupKey: string): GroupState | undefined => logicalGroupingsRef.current.get(groupKey)?.groupState, [logicalGroupingsRef] ); @@ -104,15 +109,17 @@ const ActivityLogicalGroupingComposer = (props: ActivityLogicalGroupingComposerP getGroupState, getLogicalGroupKey, removeLogicalGrouping, - subscribeLogicalGrouping + subscribeGroupState: groupStateSubscription.subscribe, + subscribeLogicalGroupKey: logicalGroupKeySubscription.subscribe }), [ addLogicalGrouping, getGroupBoundaries, getGroupState, getLogicalGroupKey, - removeLogicalGrouping, - subscribeLogicalGrouping + groupStateSubscription, + logicalGroupKeySubscription, + removeLogicalGrouping ] ); diff --git a/packages/component/src/providers/ActivityLogicalGrouping/index.ts b/packages/component/src/providers/ActivityLogicalGrouping/index.ts index 5db7ba87c1..028e84e78a 100644 --- a/packages/component/src/providers/ActivityLogicalGrouping/index.ts +++ b/packages/component/src/providers/ActivityLogicalGrouping/index.ts @@ -1,8 +1,7 @@ export { default as ActivityLogicalGroupingComposer } from './ActivityLogicalGroupingComposer'; export { default as useAddLogicalGrouping } from './useAddLogicalGrouping'; -export { default as useRemoveLogicalGrouping } from './useRemoveLogicalGrouping'; -export { default as useGetLogicalGroupKey } from './useGetLogicalGroupKey'; -export { default as useGetGroupState } from './useGetGroupState'; +export { default as useGroupState } from './useGroupState'; export { default as useLogicalGroupKey } from './useLogicalGroupKey'; +export { default as useRemoveLogicalGrouping } from './useRemoveLogicalGrouping'; export type { ActivityLogicalGroupingComposerProps } from './ActivityLogicalGroupingComposer'; export type { LogicalGrouping, GroupState } from './private/ActivityLogicalGroupingContext'; diff --git a/packages/component/src/providers/ActivityLogicalGrouping/private/ActivityLogicalGroupingContext.ts b/packages/component/src/providers/ActivityLogicalGrouping/private/ActivityLogicalGroupingContext.ts index eb523b5f0e..3d86b0afdd 100644 --- a/packages/component/src/providers/ActivityLogicalGrouping/private/ActivityLogicalGroupingContext.ts +++ b/packages/component/src/providers/ActivityLogicalGrouping/private/ActivityLogicalGroupingContext.ts @@ -1,14 +1,15 @@ import createContextAndHook from '../../createContextAndHook'; -type GroupState = Readonly<{ - isCollapsed: boolean; -}>; +type GroupState = { + readonly isCollapsed: boolean; +}; -type LogicalGrouping = Readonly<{ - activityKeys: string[]; - getGroupState?: () => GroupState; - key: string; -}>; +type LogicalGrouping = { + readonly activityKeys: readonly string[]; + /** Compared by identity: pass the same object until the state changes, so `useGroupState()` does not re-render. */ + readonly groupState?: GroupState | undefined; + readonly key: string; +}; type ActivityLogicalGroupingContextType = Readonly<{ addLogicalGrouping: (grouping: LogicalGrouping) => void; @@ -16,7 +17,8 @@ type ActivityLogicalGroupingContextType = Readonly<{ getGroupState: (groupKey: string) => GroupState | undefined; getLogicalGroupKey: (activityKey: string) => string | undefined; removeLogicalGrouping: (key: string) => void; - subscribeLogicalGrouping: (listener: () => void) => () => void; + subscribeGroupState: (groupKey: string, listener: () => void) => () => void; + subscribeLogicalGroupKey: (activityKey: string, listener: () => void) => () => void; }>; const { contextComponentType, useContext } = createContextAndHook( diff --git a/packages/component/src/providers/ActivityLogicalGrouping/private/useGetGroupState.ts b/packages/component/src/providers/ActivityLogicalGrouping/private/useGetGroupState.ts new file mode 100644 index 0000000000..c8c3bb14e1 --- /dev/null +++ b/packages/component/src/providers/ActivityLogicalGrouping/private/useGetGroupState.ts @@ -0,0 +1,11 @@ +import { useActivityLogicalGroupingContext, type GroupState } from './ActivityLogicalGroupingContext'; + +/** + * Returns a getter that reads the latest group state at call time, without subscribing. + * + * Only for keyboard navigation in `TranscriptFocusComposer`, which looks up arbitrary groups while handling a key press. + * Not exported from the provider: components that render from the group state must use `useGroupState()`. + */ +export default function useGetGroupState(): (groupKey: string) => GroupState | undefined { + return useActivityLogicalGroupingContext().getGroupState; +} diff --git a/packages/component/src/providers/ActivityLogicalGrouping/private/useGetLogicalGroupBoundaries.ts b/packages/component/src/providers/ActivityLogicalGrouping/private/useGetLogicalGroupBoundaries.ts new file mode 100644 index 0000000000..2089f7eba5 --- /dev/null +++ b/packages/component/src/providers/ActivityLogicalGrouping/private/useGetLogicalGroupBoundaries.ts @@ -0,0 +1,10 @@ +import { useActivityLogicalGroupingContext } from './ActivityLogicalGroupingContext'; + +/** + * Returns a getter that reads the latest first and last activity keys of a group at call time, without subscribing. + * + * Only for keyboard navigation in `TranscriptFocusComposer`, which looks up arbitrary groups while handling a key press. + */ +export default function useGetLogicalGroupBoundaries(): (groupKey: string) => [string | undefined, string | undefined] { + return useActivityLogicalGroupingContext().getGroupBoundaries; +} diff --git a/packages/component/src/providers/ActivityLogicalGrouping/private/useGetLogicalGroupKey.ts b/packages/component/src/providers/ActivityLogicalGrouping/private/useGetLogicalGroupKey.ts new file mode 100644 index 0000000000..8fce27991f --- /dev/null +++ b/packages/component/src/providers/ActivityLogicalGrouping/private/useGetLogicalGroupKey.ts @@ -0,0 +1,11 @@ +import { useActivityLogicalGroupingContext } from './ActivityLogicalGroupingContext'; + +/** + * Returns a getter that reads the latest group key at call time, without subscribing. + * + * Only for keyboard navigation in `TranscriptFocusComposer`, which looks up arbitrary activities while handling a key press. + * Not exported from the provider: components that render from the group key must use `useLogicalGroupKey()`. + */ +export default function useGetLogicalGroupKey(): (activityKey: string) => string | undefined { + return useActivityLogicalGroupingContext().getLogicalGroupKey; +} diff --git a/packages/component/src/providers/ActivityLogicalGrouping/useGetGroupState.ts b/packages/component/src/providers/ActivityLogicalGrouping/useGetGroupState.ts deleted file mode 100644 index 1fdbaa3acf..0000000000 --- a/packages/component/src/providers/ActivityLogicalGrouping/useGetGroupState.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { useActivityLogicalGroupingContext, type GroupState } from './private/ActivityLogicalGroupingContext'; - -export default function useGetGroupState(): (groupKey: string) => GroupState | undefined { - return useActivityLogicalGroupingContext().getGroupState; -} diff --git a/packages/component/src/providers/ActivityLogicalGrouping/useGetLogicalGroupBoundaries.ts b/packages/component/src/providers/ActivityLogicalGrouping/useGetLogicalGroupBoundaries.ts deleted file mode 100644 index bb98b739c4..0000000000 --- a/packages/component/src/providers/ActivityLogicalGrouping/useGetLogicalGroupBoundaries.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { useActivityLogicalGroupingContext } from './private/ActivityLogicalGroupingContext'; - -export default function useGetLogicalGroupBoundaries(): (groupKey: string) => [string | undefined, string | undefined] { - return useActivityLogicalGroupingContext().getGroupBoundaries; -} diff --git a/packages/component/src/providers/ActivityLogicalGrouping/useGetLogicalGroupKey.ts b/packages/component/src/providers/ActivityLogicalGrouping/useGetLogicalGroupKey.ts deleted file mode 100644 index bde0df9be9..0000000000 --- a/packages/component/src/providers/ActivityLogicalGrouping/useGetLogicalGroupKey.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { useActivityLogicalGroupingContext } from './private/ActivityLogicalGroupingContext'; - -export default function useGetLogicalGroupKey(): (activityKey: string) => string | undefined { - return useActivityLogicalGroupingContext().getLogicalGroupKey; -} diff --git a/packages/component/src/providers/ActivityLogicalGrouping/useGroupState.spec.tsx b/packages/component/src/providers/ActivityLogicalGrouping/useGroupState.spec.tsx new file mode 100644 index 0000000000..7d213697fa --- /dev/null +++ b/packages/component/src/providers/ActivityLogicalGrouping/useGroupState.spec.tsx @@ -0,0 +1,100 @@ +/** @jest-environment @happy-dom/jest-environment */ + +import 'core-js/features/set/index.js'; + +import * as JestGlobals from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; +import React, { memo } from 'react'; +import { render } from 'react-dom'; +import { act } from 'react-dom/test-utils'; + +import usePartGroupingLogicalGroup from '../../Middleware/ActivityGrouping/ui/PartGrouping/private/usePartGroupingLogicalGroup'; +import ActivityLogicalGroupingComposer from './ActivityLogicalGroupingComposer'; +import { type GroupState } from './private/ActivityLogicalGroupingContext'; +import useGroupState from './useGroupState'; + +type Renders = (GroupState | undefined)[]; + +function Reader({ groupKey, renders }: { readonly groupKey: string; readonly renders: Renders }) { + renders.push(useGroupState(groupKey)[0]); + + return null; +} + +// Memoized like activities in the transcript, so only its subscription can re-render it. +const MemoizedReader = memo(Reader); + +function PartGroup({ + activityKeys, + isCollapsed, + renders +}: { + readonly activityKeys: readonly string[]; + readonly isCollapsed: boolean; + readonly renders: Renders; +}) { + const groupKey = usePartGroupingLogicalGroup({ activityKeys, isCollapsed }); + + return ; +} + +const ACTIVITY_KEYS_1 = Object.freeze(['a']); +const ACTIVITY_KEYS_2 = Object.freeze(['b']); + +function renderGroups( + container: HTMLElement, + { isGroup1Collapsed, renders1, renders2 }: { isGroup1Collapsed: boolean; renders1: Renders; renders2: Renders } +) { + act(() => { + render( + + + + , + container + ); + }); +} + +scenario( + 'useGroupState', + bdd => { + bdd + .given('an empty transcript', (): { container: HTMLElement; renders1: Renders; renders2: Renders } => ({ + container: document.createElement('div'), + renders1: [], + renders2: [] + })) + .when('two expanded part groups are added', ({ container, renders1, renders2 }) => + renderGroups(container, { isGroup1Collapsed: false, renders1, renders2 }) + ) + .then('both readers should see an expanded group', ({ renders1, renders2 }) => { + // Rendered before the group is added in a layout effect, then re-rendered with its state before paint. + expect(renders1).toStrictEqual([undefined, { isCollapsed: false }]); + expect(renders2).toStrictEqual([undefined, { isCollapsed: false }]); + }) + .when('group 1 collapses', ({ container, renders1, renders2 }) => { + renders1.splice(0); + renders2.splice(0); + + renderGroups(container, { isGroup1Collapsed: true, renders1, renders2 }); + }) + .then('group 1 reader should re-render once, collapsed', ({ renders1 }) => + expect(renders1).toStrictEqual([{ isCollapsed: true }]) + ) + .and('group 2 reader should not re-render, its group did not change', ({ renders2 }) => + expect(renders2).toStrictEqual([]) + ) + .when('group 1 expands', ({ container, renders1, renders2 }) => { + renders1.splice(0); + renders2.splice(0); + + renderGroups(container, { isGroup1Collapsed: false, renders1, renders2 }); + }) + .then('group 1 reader should re-render once, expanded', ({ renders1 }) => + expect(renders1).toStrictEqual([{ isCollapsed: false }]) + ) + .and('group 2 reader should not re-render', ({ renders2 }) => expect(renders2).toStrictEqual([])); + }, + JestGlobals +); diff --git a/packages/component/src/providers/ActivityLogicalGrouping/useGroupState.ts b/packages/component/src/providers/ActivityLogicalGrouping/useGroupState.ts new file mode 100644 index 0000000000..c831acbc3e --- /dev/null +++ b/packages/component/src/providers/ActivityLogicalGrouping/useGroupState.ts @@ -0,0 +1,13 @@ +import useKeyedSubscriptionValue from '../../hooks/internal/keyedSubscription/useKeyedSubscriptionValue'; +import { useActivityLogicalGroupingContext, type GroupState } from './private/ActivityLogicalGroupingContext'; + +/** + * Returns the state of the logical group, such as whether it is collapsed, or `undefined` if the group does not exist or has no state. + * + * The component re-renders only when the state of this group changes. + */ +export default function useGroupState(groupKey: string): readonly [GroupState | undefined] { + const { getGroupState, subscribeGroupState } = useActivityLogicalGroupingContext(); + + return useKeyedSubscriptionValue(subscribeGroupState, groupKey, getGroupState); +} diff --git a/packages/component/src/providers/ActivityLogicalGrouping/useLogicalGroupKey.ts b/packages/component/src/providers/ActivityLogicalGrouping/useLogicalGroupKey.ts index f7cde7e87b..5122d59c1a 100644 --- a/packages/component/src/providers/ActivityLogicalGrouping/useLogicalGroupKey.ts +++ b/packages/component/src/providers/ActivityLogicalGrouping/useLogicalGroupKey.ts @@ -1,45 +1,13 @@ -import { useLayoutEffect, useMemo, useReducer, useRef } from 'react'; - +import useKeyedSubscriptionValue from '../../hooks/internal/keyedSubscription/useKeyedSubscriptionValue'; import { useActivityLogicalGroupingContext } from './private/ActivityLogicalGroupingContext'; -function increment(value: number): number { - return value + 1; -} - /** * Returns the key of the logical group the activity belongs to, or `undefined` if it is not part of any group. * * The component re-renders only when the group key of this activity changes. - * Event handlers, which read the latest value at call time, should use `useGetLogicalGroupKey()` instead. */ export default function useLogicalGroupKey(activityKey: string): readonly [string | undefined] { - const { getLogicalGroupKey, subscribeLogicalGrouping } = useActivityLogicalGroupingContext(); - const [, forceRender] = useReducer(increment, 0); - - const logicalGroupKey = getLogicalGroupKey(activityKey); - const logicalGroupKeyRef = useRef(logicalGroupKey); - - useLayoutEffect(() => { - logicalGroupKeyRef.current = logicalGroupKey; - }); - - useLayoutEffect(() => { - const handleChange = () => { - const nextLogicalGroupKey = getLogicalGroupKey(activityKey); - - if (nextLogicalGroupKey !== logicalGroupKeyRef.current) { - // Remembers the new value now, so another change before the re-render does not re-render again. - logicalGroupKeyRef.current = nextLogicalGroupKey; - - forceRender(); - } - }; - - // Catches changes between render and subscription. - handleChange(); - - return subscribeLogicalGrouping(handleChange); - }, [activityKey, forceRender, getLogicalGroupKey, logicalGroupKeyRef, subscribeLogicalGrouping]); + const { getLogicalGroupKey, subscribeLogicalGroupKey } = useActivityLogicalGroupingContext(); - return useMemo(() => Object.freeze([logicalGroupKey] as const), [logicalGroupKey]); + return useKeyedSubscriptionValue(subscribeLogicalGroupKey, activityKey, getLogicalGroupKey); } diff --git a/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.groupState.spec.tsx b/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.groupState.spec.tsx new file mode 100644 index 0000000000..809233e54d --- /dev/null +++ b/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.groupState.spec.tsx @@ -0,0 +1,94 @@ +/** @jest-environment @happy-dom/jest-environment */ +/* eslint no-magic-numbers: "off" */ + +import 'core-js/features/set/index.js'; + +import * as JestGlobals from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; +import React, { useContext } from 'react'; +import { render } from 'react-dom'; +import { act } from 'react-dom/test-utils'; + +import usePartGroupingLogicalGroup from '../../Middleware/ActivityGrouping/ui/PartGrouping/private/usePartGroupingLogicalGroup'; +import { ActivityLogicalGroupingComposer } from '../ActivityLogicalGrouping'; +import useRenderingActivityKeys from '../RenderingActivities/useRenderingActivityKeys'; +import TranscriptFocusContext, { type TranscriptFocusContextType } from './private/Context'; +import TranscriptFocusComposer from './TranscriptFocusComposer'; + +jest.mock('../../Utils/scrollIntoViewWithBlockNearest', () => jest.fn()); +jest.mock('../RenderingActivities/useRenderingActivityKeys'); + +// Every render pushes its values, the last one is current. +type Result = { readonly contexts: TranscriptFocusContextType[]; readonly groupKeys: string[] }; + +// Activity "c" follows the part group with "a" and "b". +const ACTIVITY_KEYS = Object.freeze(['a', 'b', 'c']); +const GROUP_ACTIVITY_KEYS = Object.freeze(['a', 'b']); + +function PartGroup({ isCollapsed, result }: { readonly isCollapsed: boolean; readonly result: Result }) { + result.groupKeys.push(usePartGroupingLogicalGroup({ activityKeys: GROUP_ACTIVITY_KEYS, isCollapsed })); + + return null; +} + +function ContextReader({ result }: { readonly result: Result }) { + result.contexts.push(useContext(TranscriptFocusContext)); + + return null; +} + +function renderTranscript(container: HTMLElement, isCollapsed: boolean, result: Result) { + act(() => { + render( + + + + + + , + container + ); + }); +} + +function getFocusedId({ contexts }: Result): string | undefined { + const context = contexts.at(-1); + + return (context?.activeGroupDescendantIdState[0] || context?.activeDescendantIdState[0])?.replace( + /^webchat__transcript-focus-[^_]+__/u, + '' + ); +} + +scenario( + 'TranscriptFocusComposer reads the latest group state while handling a key press', + bdd => { + bdd + .given('a transcript with an expanded part group followed by activity "c"', () => { + jest.mocked(useRenderingActivityKeys).mockReturnValue(Object.freeze([ACTIVITY_KEYS])); + + const container = document.createElement('div'); + const result: Result = { contexts: [], groupKeys: [] }; + + renderTranscript(container, false, result); + + return { container, result }; + }) + .when('pressing up from activity "c"', ({ result }) => { + act(() => result.contexts.at(-1)?.focusRelativeActivity(-1)); + }) + .then('activity "b" should be focused, inside the expanded group', ({ result }) => + expect(getFocusedId(result)).toBe('activity-b') + ) + .when('the group collapses and pressing up from activity "c" again', ({ container, result }) => { + renderTranscript(container, true, result); + + act(() => result.contexts.at(-1)?.focusByActivityKey('c', false)); + act(() => result.contexts.at(-1)?.focusRelativeActivity(-1)); + }) + .then('the collapsed group should be focused', ({ result }) => + expect(getFocusedId(result)).toBe(`group-${result.groupKeys.at(-1)}`) + ); + }, + JestGlobals +); diff --git a/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.spec.tsx b/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.spec.tsx index 83ed785169..099e2907ea 100644 --- a/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.spec.tsx +++ b/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.spec.tsx @@ -72,7 +72,7 @@ describe('TranscriptFocusComposer - focusRelativeActivity', () => { const group = groups.get(groupKey) ?? { key: groupKey, activityKeys: [], - getGroupState: () => config.groupStates[groupKey] + groupState: config.groupStates[groupKey] }; group.activityKeys.push(activityKey); diff --git a/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.tsx b/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.tsx index c5ffeb1d30..88babb00e9 100644 --- a/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.tsx +++ b/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.tsx @@ -7,8 +7,9 @@ import usePrevious from '../../hooks/internal/usePrevious'; import useStateRef from '../../hooks/internal/useStateRef'; import useValueRef from '../../hooks/internal/useValueRef'; import scrollIntoViewWithBlockNearest from '../../Utils/scrollIntoViewWithBlockNearest'; -import { useGetGroupState, useGetLogicalGroupKey } from '../ActivityLogicalGrouping'; -import useGetLogicalGroupBoundaries from '../ActivityLogicalGrouping/useGetLogicalGroupBoundaries'; +import useGetGroupState from '../ActivityLogicalGrouping/private/useGetGroupState'; +import useGetLogicalGroupKey from '../ActivityLogicalGrouping/private/useGetLogicalGroupKey'; +import useGetLogicalGroupBoundaries from '../ActivityLogicalGrouping/private/useGetLogicalGroupBoundaries'; import useRenderingActivityKeys from '../RenderingActivities/useRenderingActivityKeys'; import TranscriptFocusContext, { type TranscriptFocusContextType } from './private/Context'; @@ -29,6 +30,8 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp >(); const [renderingActivityKeys] = useRenderingActivityKeys(); const [, setRawFocusedActivityKey, rawFocusedActivityKeyRef] = useStateRef(); + // Keyboard navigation looks up arbitrary activities and groups while handling a key press, and nothing here renders from them. + // Thus, read them at call time instead of subscribing with `useLogicalGroupKey()` and `useGroupState()`. const getGroupBoundaries = useGetLogicalGroupBoundaries(); const getGroupKeyByActivityKey = useGetLogicalGroupKey(); const getGroupState = useGetGroupState(); diff --git a/packages/component/src/providers/TranscriptFocus/useGetGroupDescendantIdByGroupKey.ts b/packages/component/src/providers/TranscriptFocus/useGetGroupDescendantIdByGroupKey.ts deleted file mode 100644 index 73bfa74da6..0000000000 --- a/packages/component/src/providers/TranscriptFocus/useGetGroupDescendantIdByGroupKey.ts +++ /dev/null @@ -1,5 +0,0 @@ -import useTranscriptFocusContext from './private/useContext'; - -export default function useGetGroupDescendantIdByGroupKey(): (groupKey?: string) => string | undefined { - return useTranscriptFocusContext().getGroupDescendantIdByGroupKey; -} diff --git a/packages/component/src/providers/TranscriptFocus/useGroupDescendantIdByGroupKey.ts b/packages/component/src/providers/TranscriptFocus/useGroupDescendantIdByGroupKey.ts new file mode 100644 index 0000000000..28428f94d6 --- /dev/null +++ b/packages/component/src/providers/TranscriptFocus/useGroupDescendantIdByGroupKey.ts @@ -0,0 +1,17 @@ +import { useMemo } from 'react'; + +import useTranscriptFocusContext from './private/useContext'; + +/** + * Returns the ID of the group's active descendant element, for `aria-activedescendant`. + * + * 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(); + + return useMemo( + () => Object.freeze([getGroupDescendantIdByGroupKey(groupKey)] as const), + [getGroupDescendantIdByGroupKey, groupKey] + ); +} From b9d83a6cf723b93e2a868e9948d3d14f6260acc1 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 22 Oct 2026 17:29:40 -0700 Subject: [PATCH 10/18] Keep a newer listener set on a repeated unsubscribe; test moving an activity between groups --- .../createKeyedSubscription.spec.ts | 29 +++++++++++++++++++ .../createKeyedSubscription.ts | 4 ++- .../ActivityLogicalGroupingComposer.spec.tsx | 17 +++++++++-- .../private/ActivityLogicalGroupingContext.ts | 18 ++++++------ 4 files changed, 56 insertions(+), 12 deletions(-) diff --git a/packages/component/src/hooks/internal/keyedSubscription/createKeyedSubscription.spec.ts b/packages/component/src/hooks/internal/keyedSubscription/createKeyedSubscription.spec.ts index 11ffdc0b5d..1931a3f3c5 100644 --- a/packages/component/src/hooks/internal/keyedSubscription/createKeyedSubscription.spec.ts +++ b/packages/component/src/hooks/internal/keyedSubscription/createKeyedSubscription.spec.ts @@ -36,3 +36,32 @@ scenario( }, JestGlobals ); + +scenario( + 'createKeyedSubscription with an unsubscribe called twice', + bdd => { + bdd + .given('a listener of "a" unsubscribed, then a new listener of "a"', () => { + const { notify, subscribe } = createKeyedSubscription(); + const prevListener = JestGlobals.jest.fn(); + const unsubscribePrev = subscribe('a', prevListener); + + unsubscribePrev(); + + const nextListener = JestGlobals.jest.fn(); + + subscribe('a', nextListener); + + return { nextListener, notify, prevListener, unsubscribePrev }; + }) + .when('the first unsubscribe is called again and "a" is notified', ({ notify, unsubscribePrev }) => { + unsubscribePrev(); + notify(['a']); + }) + .then('the new listener should still be called', ({ nextListener, prevListener }) => { + expect(nextListener).toHaveBeenCalledTimes(1); + expect(prevListener).not.toHaveBeenCalled(); + }); + }, + JestGlobals +); diff --git a/packages/component/src/hooks/internal/keyedSubscription/createKeyedSubscription.ts b/packages/component/src/hooks/internal/keyedSubscription/createKeyedSubscription.ts index 62b89ab0f3..552e9a0a46 100644 --- a/packages/component/src/hooks/internal/keyedSubscription/createKeyedSubscription.ts +++ b/packages/component/src/hooks/internal/keyedSubscription/createKeyedSubscription.ts @@ -26,7 +26,9 @@ export default function createKeyedSubscription(): KeyedSubscription { return () => { listeners.delete(listener); - listeners.size || listenersByKey.delete(key); + + // Deletes only this set: after it emptied, a later subscribe may have put a new set under the same key. + !listeners.size && listenersByKey.get(key) === listeners && listenersByKey.delete(key); }; } }); diff --git a/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.spec.tsx b/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.spec.tsx index 2c5846fa24..a394c4ef70 100644 --- a/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.spec.tsx +++ b/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.spec.tsx @@ -78,15 +78,28 @@ scenario( expect(listeners.b).not.toHaveBeenCalled(); expect(listeners.g).toHaveBeenCalledTimes(1); }) + .when('"a" moves from group "g" to group "h"', ({ context, listeners }) => { + Object.values(listeners).forEach(listener => listener.mockClear()); + + context.addLogicalGrouping({ activityKeys: ['a'], groupState: EXPANDED, key: 'h' }); + }) + .then('only "a" should be notified, and its group key should read "h"', ({ context, listeners }) => { + expect(listeners.a).toHaveBeenCalledTimes(1); + expect(listeners.b).not.toHaveBeenCalled(); + expect(listeners.g).not.toHaveBeenCalled(); + expect(context.getLogicalGroupKey('a')).toBe('h'); + }) .when('group "g" is removed', ({ context, listeners }) => { Object.values(listeners).forEach(listener => listener.mockClear()); context.removeLogicalGrouping('g'); }) - .then('"a", "b" and "g" should be notified', ({ listeners }) => { - expect(listeners.a).toHaveBeenCalledTimes(1); + .then('"b" and "g" should be notified, "a" should stay in "h"', ({ context, listeners }) => { + expect(listeners.a).not.toHaveBeenCalled(); // "a" moved to "h" before "g" was removed. expect(listeners.b).toHaveBeenCalledTimes(1); expect(listeners.g).toHaveBeenCalledTimes(1); + expect(context.getLogicalGroupKey('a')).toBe('h'); + expect(context.getLogicalGroupKey('b')).toBeUndefined(); }); }, JestGlobals diff --git a/packages/component/src/providers/ActivityLogicalGrouping/private/ActivityLogicalGroupingContext.ts b/packages/component/src/providers/ActivityLogicalGrouping/private/ActivityLogicalGroupingContext.ts index 3d86b0afdd..ae9290ec6d 100644 --- a/packages/component/src/providers/ActivityLogicalGrouping/private/ActivityLogicalGroupingContext.ts +++ b/packages/component/src/providers/ActivityLogicalGrouping/private/ActivityLogicalGroupingContext.ts @@ -11,15 +11,15 @@ type LogicalGrouping = { readonly key: string; }; -type ActivityLogicalGroupingContextType = Readonly<{ - addLogicalGrouping: (grouping: LogicalGrouping) => void; - getGroupBoundaries: (groupKey: string) => [string | undefined, string | undefined]; - getGroupState: (groupKey: string) => GroupState | undefined; - getLogicalGroupKey: (activityKey: string) => string | undefined; - removeLogicalGrouping: (key: string) => void; - subscribeGroupState: (groupKey: string, listener: () => void) => () => void; - subscribeLogicalGroupKey: (activityKey: string, listener: () => void) => () => void; -}>; +type ActivityLogicalGroupingContextType = { + readonly addLogicalGrouping: (grouping: LogicalGrouping) => void; + readonly getGroupBoundaries: (groupKey: string) => [string | undefined, string | undefined]; + readonly getGroupState: (groupKey: string) => GroupState | undefined; + readonly getLogicalGroupKey: (activityKey: string) => string | undefined; + readonly removeLogicalGrouping: (key: string) => void; + readonly subscribeGroupState: (groupKey: string, listener: () => void) => () => void; + readonly subscribeLogicalGroupKey: (activityKey: string, listener: () => void) => () => void; +}; const { contextComponentType, useContext } = createContextAndHook( 'ActivityLogicalGroupingContext' From 1e884ffd841e5d38cb6c500581ee5bcb1c655da5 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 22 Oct 2026 18:03:55 -0700 Subject: [PATCH 11/18] useKeyedSubscriptionValue: re-render with useState instead of a ref and a forced render Test switching the key and function values. --- .../useKeyedSubscriptionValue.spec.tsx | 74 +++++++++++++++++++ .../useKeyedSubscriptionValue.ts | 28 ++----- 2 files changed, 80 insertions(+), 22 deletions(-) diff --git a/packages/component/src/hooks/internal/keyedSubscription/useKeyedSubscriptionValue.spec.tsx b/packages/component/src/hooks/internal/keyedSubscription/useKeyedSubscriptionValue.spec.tsx index 0c58fbe9b7..b0af1ee075 100644 --- a/packages/component/src/hooks/internal/keyedSubscription/useKeyedSubscriptionValue.spec.tsx +++ b/packages/component/src/hooks/internal/keyedSubscription/useKeyedSubscriptionValue.spec.tsx @@ -109,6 +109,80 @@ scenario( .then('the reader should re-render with the missed value', ({ store }) => expect(store.renders.a).toStrictEqual([undefined, 'missed']) ); + + bdd + .given('a reader of "a", "a" is "1" and "b" is "2"', () => { + const container = document.createElement('div'); + const store = createStore(); + + store.values.set('a', '1'); + store.values.set('b', '2'); + + act(() => { + render(, container); + }); + + return { container, store }; + }) + .when('the reader switches to "b"', ({ container, store }) => { + act(() => { + render(, container); + }); + }) + .then('the reader should render "2" from its first render with "b"', ({ store }) => { + expect(store.renders.a).toStrictEqual(['1']); + // Re-renders once: its state holds "1" until the re-check after subscribing to "b". + expect(store.renders.b).toStrictEqual(['2', '2']); + }) + .when('"a" changes and is notified', ({ store }) => { + store.values.set('a', '3'); + + act(() => store.subscription.notify(['a'])); + }) + .then('the reader should not re-render, it unsubscribed from "a"', ({ store }) => + expect(store.renders.b).toStrictEqual(['2', '2']) + ) + .when('"b" changes and is notified', ({ store }) => { + store.values.set('b', '4'); + + act(() => store.subscription.notify(['b'])); + }) + .then('the reader should re-render with the new value of "b"', ({ store }) => + expect(store.renders.b).toStrictEqual(['2', '2', '4']) + ); + + bdd + .given('a reader of a value which is a function', () => { + const container = document.createElement('div'); + const renders: (() => void)[] = []; + const subscription = createKeyedSubscription(); + const values = { current: JestGlobals.jest.fn() }; + const getValue = () => values.current; + + function FunctionReader() { + renders.push(useKeyedSubscriptionValue(subscription.subscribe, 'a', getValue)[0]); + + return null; + } + + act(() => { + render(, container); + }); + + return { initialValue: values.current, renders, subscription, values }; + }) + .when('the value changes to another function and is notified', ({ subscription, values }) => { + values.current = JestGlobals.jest.fn(); + + act(() => subscription.notify(['a'])); + }) + .then('the reader should re-render with the new function', ({ initialValue, renders, values }) => + expect(renders).toStrictEqual([initialValue, values.current]) + ) + .and('neither function should be called, they are values, not state updaters', ({ initialValue, values }) => { + expect(initialValue).not.toHaveBeenCalled(); + expect(values.current).not.toHaveBeenCalled(); + }); }, JestGlobals ); diff --git a/packages/component/src/hooks/internal/keyedSubscription/useKeyedSubscriptionValue.ts b/packages/component/src/hooks/internal/keyedSubscription/useKeyedSubscriptionValue.ts index 845217e5fc..95e9aff4c2 100644 --- a/packages/component/src/hooks/internal/keyedSubscription/useKeyedSubscriptionValue.ts +++ b/packages/component/src/hooks/internal/keyedSubscription/useKeyedSubscriptionValue.ts @@ -1,11 +1,7 @@ -import { useLayoutEffect, useMemo, useReducer, useRef } from 'react'; +import { useLayoutEffect, useMemo, useState } from 'react'; import { type KeyedSubscription } from './createKeyedSubscription'; -function increment(value: number): number { - return value + 1; -} - /** * Returns `[getValue(key)]` and re-renders when its result changes (compared by identity) after `key` is notified. * @@ -16,32 +12,20 @@ export default function useKeyedSubscriptionValue( key: K, getValue: (key: K) => T ): readonly [T] { - const [, forceRender] = useReducer(increment, 0); - const value = getValue(key); - const valueRef = useRef(value); - useLayoutEffect(() => { - valueRef.current = value; - }); + // The state only triggers re-renders, React skips them when the value did not change. + // Function forms: React would call a function `T` as an initializer or updater. + const [, setValue] = useState(() => value); useLayoutEffect(() => { - const handleChange = () => { - const nextValue = getValue(key); - - if (nextValue !== valueRef.current) { - // Remembers the new value now, so another notification before the re-render does not re-render again. - valueRef.current = nextValue; - - forceRender(); - } - }; + const handleChange = () => setValue(() => getValue(key)); // Catches changes between render and subscription. handleChange(); return subscribe(key, handleChange); - }, [forceRender, getValue, key, subscribe, valueRef]); + }, [getValue, key, subscribe]); return useMemo(() => Object.freeze([value] as const), [value]); } From 39e625e51b653706c8cf38df28b181eacba8a9a4 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 22 Oct 2026 18:06:32 -0700 Subject: [PATCH 12/18] usePartGroupingLogicalGroup: create the group key with useMemo --- .../ui/PartGrouping/private/usePartGroupingLogicalGroup.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/component/src/Middleware/ActivityGrouping/ui/PartGrouping/private/usePartGroupingLogicalGroup.ts b/packages/component/src/Middleware/ActivityGrouping/ui/PartGrouping/private/usePartGroupingLogicalGroup.ts index c3e604e172..c12eab108c 100644 --- a/packages/component/src/Middleware/ActivityGrouping/ui/PartGrouping/private/usePartGroupingLogicalGroup.ts +++ b/packages/component/src/Middleware/ActivityGrouping/ui/PartGrouping/private/usePartGroupingLogicalGroup.ts @@ -1,5 +1,5 @@ import random from 'math-random'; -import { useLayoutEffect, useMemo, useState } from 'react'; +import { useLayoutEffect, useMemo } from 'react'; import { useAddLogicalGrouping, useRemoveLogicalGrouping } from '../../../../../providers/ActivityLogicalGrouping'; @@ -10,7 +10,7 @@ type UsePartGroupingLogicalGroupOptions = { function usePartGroupingLogicalGroup({ activityKeys, isCollapsed }: UsePartGroupingLogicalGroupOptions): string { // eslint-disable-next-line no-magic-numbers - const [partGroupKey] = useState(() => `part-${random().toString(36).slice(2, 11)}`); + const partGroupKey = useMemo(() => `part-${random().toString(36).slice(2, 11)}`, []); const addLogicalGrouping = useAddLogicalGrouping(); const removeLogicalGrouping = useRemoveLogicalGrouping(); From b7bb61d3558b1e9bc3b77025cf45cfcc3c74f3c1 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 22 Oct 2026 18:06:33 -0700 Subject: [PATCH 13/18] Rename addLogicalGrouping to setLogicalGrouping(key, grouping), symmetric with removeLogicalGrouping(key) It adds or replaces the grouping with the same key. LogicalGrouping no longer carries the key. --- .../private/usePartGroupingLogicalGroup.ts | 14 +++++------- .../ActivityLogicalGroupingComposer.spec.tsx | 8 +++---- .../ActivityLogicalGroupingComposer.ts | 22 +++++++++---------- .../ActivityLogicalGrouping/index.ts | 2 +- .../private/ActivityLogicalGroupingContext.ts | 4 ++-- .../useAddLogicalGrouping.ts | 5 ----- .../useLogicalGroupKey.spec.tsx | 8 +++---- .../useSetLogicalGrouping.ts | 5 +++++ .../TranscriptFocusComposer.spec.tsx | 11 +++++----- 9 files changed, 37 insertions(+), 42 deletions(-) delete mode 100644 packages/component/src/providers/ActivityLogicalGrouping/useAddLogicalGrouping.ts create mode 100644 packages/component/src/providers/ActivityLogicalGrouping/useSetLogicalGrouping.ts diff --git a/packages/component/src/Middleware/ActivityGrouping/ui/PartGrouping/private/usePartGroupingLogicalGroup.ts b/packages/component/src/Middleware/ActivityGrouping/ui/PartGrouping/private/usePartGroupingLogicalGroup.ts index c12eab108c..c297a3123d 100644 --- a/packages/component/src/Middleware/ActivityGrouping/ui/PartGrouping/private/usePartGroupingLogicalGroup.ts +++ b/packages/component/src/Middleware/ActivityGrouping/ui/PartGrouping/private/usePartGroupingLogicalGroup.ts @@ -1,7 +1,7 @@ import random from 'math-random'; import { useLayoutEffect, useMemo } from 'react'; -import { useAddLogicalGrouping, useRemoveLogicalGrouping } from '../../../../../providers/ActivityLogicalGrouping'; +import { useRemoveLogicalGrouping, useSetLogicalGrouping } from '../../../../../providers/ActivityLogicalGrouping'; type UsePartGroupingLogicalGroupOptions = { activityKeys: readonly string[]; @@ -11,22 +11,18 @@ type UsePartGroupingLogicalGroupOptions = { function usePartGroupingLogicalGroup({ activityKeys, isCollapsed }: UsePartGroupingLogicalGroupOptions): string { // eslint-disable-next-line no-magic-numbers const partGroupKey = useMemo(() => `part-${random().toString(36).slice(2, 11)}`, []); - const addLogicalGrouping = useAddLogicalGrouping(); const removeLogicalGrouping = useRemoveLogicalGrouping(); + const setLogicalGrouping = useSetLogicalGrouping(); // Same object until collapsed or expanded, so `useGroupState()` re-renders only then. const groupState = useMemo(() => Object.freeze({ isCollapsed }), [isCollapsed]); - // Adds the group in an effect, not while rendering: Strict Mode runs the cleanup below, then runs this effect again. + // Sets the group in an effect, not while rendering: Strict Mode runs the cleanup below, then runs this effect again. // A layout effect, so activities using `useLogicalGroupKey()` or `useGroupState()` re-render before the browser paints. // Collapsing or expanding the group updates it here, which notifies `useGroupState()`. useLayoutEffect(() => { - addLogicalGrouping({ - activityKeys: Object.freeze(Array.from(activityKeys)), - groupState, - key: partGroupKey - }); - }, [activityKeys, addLogicalGrouping, groupState, partGroupKey]); + setLogicalGrouping(partGroupKey, { activityKeys: Object.freeze(Array.from(activityKeys)), groupState }); + }, [activityKeys, groupState, partGroupKey, setLogicalGrouping]); useLayoutEffect(() => () => removeLogicalGrouping(partGroupKey), [partGroupKey, removeLogicalGrouping]); diff --git a/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.spec.tsx b/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.spec.tsx index a394c4ef70..1bbd23616c 100644 --- a/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.spec.tsx +++ b/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.spec.tsx @@ -51,7 +51,7 @@ scenario( return { context, listeners }; }) .when('group "g" is added with "a"', ({ context }) => { - context.addLogicalGrouping({ activityKeys: ['a'], groupState: EXPANDED, key: 'g' }); + context.setLogicalGrouping('g', { activityKeys: ['a'], groupState: EXPANDED }); }) .then('"a" and "g" should be notified, not "b"', ({ listeners }) => { expect(listeners.a).toHaveBeenCalledTimes(1); @@ -61,7 +61,7 @@ scenario( .when('group "g" is updated with "b" added, same state', ({ context, listeners }) => { Object.values(listeners).forEach(listener => listener.mockClear()); - context.addLogicalGrouping({ activityKeys: ['a', 'b'], groupState: EXPANDED, key: 'g' }); + context.setLogicalGrouping('g', { activityKeys: ['a', 'b'], groupState: EXPANDED }); }) .then('only "b" should be notified', ({ listeners }) => { expect(listeners.a).not.toHaveBeenCalled(); @@ -71,7 +71,7 @@ scenario( .when('group "g" collapses', ({ context, listeners }) => { Object.values(listeners).forEach(listener => listener.mockClear()); - context.addLogicalGrouping({ activityKeys: ['a', 'b'], groupState: COLLAPSED, key: 'g' }); + context.setLogicalGrouping('g', { activityKeys: ['a', 'b'], groupState: COLLAPSED }); }) .then('only "g" should be notified', ({ listeners }) => { expect(listeners.a).not.toHaveBeenCalled(); @@ -81,7 +81,7 @@ scenario( .when('"a" moves from group "g" to group "h"', ({ context, listeners }) => { Object.values(listeners).forEach(listener => listener.mockClear()); - context.addLogicalGrouping({ activityKeys: ['a'], groupState: EXPANDED, key: 'h' }); + context.setLogicalGrouping('h', { activityKeys: ['a'], groupState: EXPANDED }); }) .then('only "a" should be notified, and its group key should read "h"', ({ context, listeners }) => { expect(listeners.a).toHaveBeenCalledTimes(1); diff --git a/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts b/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts index 968dde7b4a..3b16a344cc 100644 --- a/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts +++ b/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts @@ -42,13 +42,13 @@ const ActivityLogicalGroupingComposer = (props: ActivityLogicalGroupingComposerP [activityToGroupMapRef, logicalGroupKeySubscription] ); - // Adding and removing notify `useGroupState()` and `useLogicalGroupKey()`, which re-render: call them from effects, never while rendering. - const addLogicalGrouping = useCallback( - (grouping: LogicalGrouping) => { - const prevGrouping = logicalGroupingsRef.current.get(grouping.key); + // Setting and removing notify `useGroupState()` and `useLogicalGroupKey()`, which re-render: call them from effects, never while rendering. + const setLogicalGrouping = useCallback( + (key: string, grouping: LogicalGrouping) => { + const prevGrouping = logicalGroupingsRef.current.get(key); const prevGroupingKeys = prevGrouping?.activityKeys ?? []; - logicalGroupingsRef.current.set(grouping.key, grouping); + logicalGroupingsRef.current.set(key, grouping); const toRemoveSet = new Set(prevGroupingKeys).difference(new Set(grouping.activityKeys)); const toAddSet = new Set(grouping.activityKeys).difference(new Set(prevGroupingKeys)); @@ -57,14 +57,14 @@ const ActivityLogicalGroupingComposer = (props: ActivityLogicalGroupingComposerP new Map( [].concat( Array.from(activityToGroupMapRef.current).filter( - ([activityKey, groupKey]) => !!groupKey && (groupKey !== grouping.key || !toRemoveSet.has(activityKey)) + ([activityKey, groupKey]) => !!groupKey && (groupKey !== key || !toRemoveSet.has(activityKey)) ), - Array.from(toAddSet).map(activityKey => [activityKey, grouping.key] as const) + Array.from(toAddSet).map(activityKey => [activityKey, key] as const) ) ) ); - prevGrouping?.groupState !== grouping.groupState && groupStateSubscription.notify([grouping.key]); + prevGrouping?.groupState !== grouping.groupState && groupStateSubscription.notify([key]); }, [activityToGroupMapRef, groupStateSubscription, logicalGroupingsRef, setActivityToGroupMap] ); @@ -104,22 +104,22 @@ const ActivityLogicalGroupingComposer = (props: ActivityLogicalGroupingComposerP const contextValue: ActivityLogicalGroupingContextType = useMemo( () => Object.freeze({ - addLogicalGrouping, getGroupBoundaries, getGroupState, getLogicalGroupKey, removeLogicalGrouping, + setLogicalGrouping, subscribeGroupState: groupStateSubscription.subscribe, subscribeLogicalGroupKey: logicalGroupKeySubscription.subscribe }), [ - addLogicalGrouping, getGroupBoundaries, getGroupState, getLogicalGroupKey, groupStateSubscription, logicalGroupKeySubscription, - removeLogicalGrouping + removeLogicalGrouping, + setLogicalGrouping ] ); diff --git a/packages/component/src/providers/ActivityLogicalGrouping/index.ts b/packages/component/src/providers/ActivityLogicalGrouping/index.ts index 028e84e78a..9285a7b787 100644 --- a/packages/component/src/providers/ActivityLogicalGrouping/index.ts +++ b/packages/component/src/providers/ActivityLogicalGrouping/index.ts @@ -1,7 +1,7 @@ export { default as ActivityLogicalGroupingComposer } from './ActivityLogicalGroupingComposer'; -export { default as useAddLogicalGrouping } from './useAddLogicalGrouping'; export { default as useGroupState } from './useGroupState'; export { default as useLogicalGroupKey } from './useLogicalGroupKey'; export { default as useRemoveLogicalGrouping } from './useRemoveLogicalGrouping'; +export { default as useSetLogicalGrouping } from './useSetLogicalGrouping'; export type { ActivityLogicalGroupingComposerProps } from './ActivityLogicalGroupingComposer'; export type { LogicalGrouping, GroupState } from './private/ActivityLogicalGroupingContext'; diff --git a/packages/component/src/providers/ActivityLogicalGrouping/private/ActivityLogicalGroupingContext.ts b/packages/component/src/providers/ActivityLogicalGrouping/private/ActivityLogicalGroupingContext.ts index ae9290ec6d..1a71ff14df 100644 --- a/packages/component/src/providers/ActivityLogicalGrouping/private/ActivityLogicalGroupingContext.ts +++ b/packages/component/src/providers/ActivityLogicalGrouping/private/ActivityLogicalGroupingContext.ts @@ -8,15 +8,15 @@ type LogicalGrouping = { readonly activityKeys: readonly string[]; /** Compared by identity: pass the same object until the state changes, so `useGroupState()` does not re-render. */ readonly groupState?: GroupState | undefined; - readonly key: string; }; type ActivityLogicalGroupingContextType = { - readonly addLogicalGrouping: (grouping: LogicalGrouping) => void; readonly getGroupBoundaries: (groupKey: string) => [string | undefined, string | undefined]; readonly getGroupState: (groupKey: string) => GroupState | undefined; readonly getLogicalGroupKey: (activityKey: string) => string | undefined; readonly removeLogicalGrouping: (key: string) => void; + /** Adds the logical grouping, or replaces the one with the same key. */ + readonly setLogicalGrouping: (key: string, grouping: LogicalGrouping) => void; readonly subscribeGroupState: (groupKey: string, listener: () => void) => () => void; readonly subscribeLogicalGroupKey: (activityKey: string, listener: () => void) => () => void; }; diff --git a/packages/component/src/providers/ActivityLogicalGrouping/useAddLogicalGrouping.ts b/packages/component/src/providers/ActivityLogicalGrouping/useAddLogicalGrouping.ts deleted file mode 100644 index 0fad15042d..0000000000 --- a/packages/component/src/providers/ActivityLogicalGrouping/useAddLogicalGrouping.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { useActivityLogicalGroupingContext, type LogicalGrouping } from './private/ActivityLogicalGroupingContext'; - -export default function useAddLogicalGrouping(): (grouping: LogicalGrouping) => void { - return useActivityLogicalGroupingContext().addLogicalGrouping; -} diff --git a/packages/component/src/providers/ActivityLogicalGrouping/useLogicalGroupKey.spec.tsx b/packages/component/src/providers/ActivityLogicalGrouping/useLogicalGroupKey.spec.tsx index 4ef22e7e7f..ef5825aea1 100644 --- a/packages/component/src/providers/ActivityLogicalGrouping/useLogicalGroupKey.spec.tsx +++ b/packages/component/src/providers/ActivityLogicalGrouping/useLogicalGroupKey.spec.tsx @@ -9,17 +9,17 @@ import { render } from 'react-dom'; import { act } from 'react-dom/test-utils'; import ActivityLogicalGroupingComposer from './ActivityLogicalGroupingComposer'; -import useAddLogicalGrouping from './useAddLogicalGrouping'; import useLogicalGroupKey from './useLogicalGroupKey'; +import useSetLogicalGrouping from './useSetLogicalGrouping'; type Renders = { readonly a: (string | undefined)[]; readonly b: (string | undefined)[] }; function Group({ activityKeys }: { readonly activityKeys: string[] }) { - const addLogicalGrouping = useAddLogicalGrouping(); + const setLogicalGrouping = useSetLogicalGrouping(); useLayoutEffect(() => { - addLogicalGrouping({ activityKeys, key: 'g' }); - }, [activityKeys, addLogicalGrouping]); + setLogicalGrouping('g', { activityKeys }); + }, [activityKeys, setLogicalGrouping]); return null; } diff --git a/packages/component/src/providers/ActivityLogicalGrouping/useSetLogicalGrouping.ts b/packages/component/src/providers/ActivityLogicalGrouping/useSetLogicalGrouping.ts new file mode 100644 index 0000000000..b54cfd4140 --- /dev/null +++ b/packages/component/src/providers/ActivityLogicalGrouping/useSetLogicalGrouping.ts @@ -0,0 +1,5 @@ +import { useActivityLogicalGroupingContext, type LogicalGrouping } from './private/ActivityLogicalGroupingContext'; + +export default function useSetLogicalGrouping(): (key: string, grouping: LogicalGrouping) => void { + return useActivityLogicalGroupingContext().setLogicalGrouping; +} diff --git a/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.spec.tsx b/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.spec.tsx index 099e2907ea..2f37387f57 100644 --- a/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.spec.tsx +++ b/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.spec.tsx @@ -10,7 +10,7 @@ import { act } from 'react-dom/test-utils'; import TranscriptFocusComposer from './TranscriptFocusComposer'; import TranscriptFocusContext, { TranscriptFocusContextType } from './private/Context'; -import { ActivityLogicalGroupingComposer, useAddLogicalGrouping } from '../ActivityLogicalGrouping'; +import { ActivityLogicalGroupingComposer, useSetLogicalGrouping } from '../ActivityLogicalGrouping'; // Mock dependencies jest.mock('../../Utils/scrollIntoViewWithBlockNearest', () => jest.fn()); @@ -55,7 +55,7 @@ describe('TranscriptFocusComposer - focusRelativeActivity', () => { const TestComponent = () => { const context = useContext(TranscriptFocusContext); - const addLogicalGrouping = useAddLogicalGrouping(); + const setLogicalGrouping = useSetLogicalGrouping(); useMemo(() => { Object.assign(result.context, context); @@ -70,7 +70,6 @@ describe('TranscriptFocusComposer - focusRelativeActivity', () => { const groups = new Map(); for (const [activityKey, groupKey] of Object.entries(config.groupKeyMappings)) { const group = groups.get(groupKey) ?? { - key: groupKey, activityKeys: [], groupState: config.groupStates[groupKey] }; @@ -79,10 +78,10 @@ describe('TranscriptFocusComposer - focusRelativeActivity', () => { groups.set(groupKey, group); } - for (const [, group] of groups.entries()) { - addLogicalGrouping(group); + for (const [groupKey, group] of groups.entries()) { + setLogicalGrouping(groupKey, group); } - }, [addLogicalGrouping]); + }, [setLogicalGrouping]); return null; }; From adecbdffd4da3b8d1d29f8679545530c120aaf85 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 22 Oct 2026 18:15:37 -0700 Subject: [PATCH 14/18] Store isCollapsed on the logical grouping instead of a group state object useGroupState() becomes useGroupIsCollapsed(): readonly [boolean | undefined], notified only when the boolean changes. --- .../private/usePartGroupingLogicalGroup.ts | 11 +++---- .../ActivityLogicalGroupingComposer.spec.tsx | 15 ++++----- .../ActivityLogicalGroupingComposer.ts | 25 +++++++------- .../ActivityLogicalGrouping/index.ts | 4 +-- .../private/ActivityLogicalGroupingContext.ts | 12 ++----- .../private/useGetGroupIsCollapsed.ts | 11 +++++++ .../private/useGetGroupState.ts | 11 ------- ....spec.tsx => useGroupIsCollapsed.spec.tsx} | 23 +++++-------- .../useGroupIsCollapsed.ts | 13 ++++++++ .../ActivityLogicalGrouping/useGroupState.ts | 13 -------- .../useLogicalGroupKey.spec.tsx | 2 +- ...ptFocusComposer.groupIsCollapsed.spec.tsx} | 2 +- .../TranscriptFocusComposer.spec.tsx | 2 +- .../TranscriptFocusComposer.tsx | 33 ++++++++----------- 14 files changed, 77 insertions(+), 100 deletions(-) create mode 100644 packages/component/src/providers/ActivityLogicalGrouping/private/useGetGroupIsCollapsed.ts delete mode 100644 packages/component/src/providers/ActivityLogicalGrouping/private/useGetGroupState.ts rename packages/component/src/providers/ActivityLogicalGrouping/{useGroupState.spec.tsx => useGroupIsCollapsed.spec.tsx} (83%) create mode 100644 packages/component/src/providers/ActivityLogicalGrouping/useGroupIsCollapsed.ts delete mode 100644 packages/component/src/providers/ActivityLogicalGrouping/useGroupState.ts rename packages/component/src/providers/TranscriptFocus/{TranscriptFocusComposer.groupState.spec.tsx => TranscriptFocusComposer.groupIsCollapsed.spec.tsx} (97%) diff --git a/packages/component/src/Middleware/ActivityGrouping/ui/PartGrouping/private/usePartGroupingLogicalGroup.ts b/packages/component/src/Middleware/ActivityGrouping/ui/PartGrouping/private/usePartGroupingLogicalGroup.ts index c297a3123d..b201c75053 100644 --- a/packages/component/src/Middleware/ActivityGrouping/ui/PartGrouping/private/usePartGroupingLogicalGroup.ts +++ b/packages/component/src/Middleware/ActivityGrouping/ui/PartGrouping/private/usePartGroupingLogicalGroup.ts @@ -14,15 +14,12 @@ function usePartGroupingLogicalGroup({ activityKeys, isCollapsed }: UsePartGroup const removeLogicalGrouping = useRemoveLogicalGrouping(); const setLogicalGrouping = useSetLogicalGrouping(); - // Same object until collapsed or expanded, so `useGroupState()` re-renders only then. - const groupState = useMemo(() => Object.freeze({ isCollapsed }), [isCollapsed]); - // Sets the group in an effect, not while rendering: Strict Mode runs the cleanup below, then runs this effect again. - // A layout effect, so activities using `useLogicalGroupKey()` or `useGroupState()` re-render before the browser paints. - // Collapsing or expanding the group updates it here, which notifies `useGroupState()`. + // A layout effect, so activities using `useLogicalGroupKey()` or `useGroupIsCollapsed()` re-render before the browser paints. + // Collapsing or expanding the group updates it here, which notifies `useGroupIsCollapsed()`. useLayoutEffect(() => { - setLogicalGrouping(partGroupKey, { activityKeys: Object.freeze(Array.from(activityKeys)), groupState }); - }, [activityKeys, groupState, partGroupKey, setLogicalGrouping]); + setLogicalGrouping(partGroupKey, { activityKeys: Object.freeze(Array.from(activityKeys)), isCollapsed }); + }, [activityKeys, isCollapsed, partGroupKey, setLogicalGrouping]); useLayoutEffect(() => () => removeLogicalGrouping(partGroupKey), [partGroupKey, removeLogicalGrouping]); diff --git a/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.spec.tsx b/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.spec.tsx index 1bbd23616c..911b906d8c 100644 --- a/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.spec.tsx +++ b/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.spec.tsx @@ -20,9 +20,6 @@ function ContextReader({ contexts }: { readonly contexts: ActivityLogicalGroupin return null; } -const COLLAPSED = Object.freeze({ isCollapsed: true }); -const EXPANDED = Object.freeze({ isCollapsed: false }); - scenario( 'ActivityLogicalGroupingComposer notifies only the keys that changed', bdd => { @@ -46,22 +43,22 @@ scenario( context.subscribeLogicalGroupKey('a', listeners.a); context.subscribeLogicalGroupKey('b', listeners.b); - context.subscribeGroupState('g', listeners.g); + context.subscribeGroupIsCollapsed('g', listeners.g); return { context, listeners }; }) .when('group "g" is added with "a"', ({ context }) => { - context.setLogicalGrouping('g', { activityKeys: ['a'], groupState: EXPANDED }); + context.setLogicalGrouping('g', { activityKeys: ['a'], isCollapsed: false }); }) .then('"a" and "g" should be notified, not "b"', ({ listeners }) => { expect(listeners.a).toHaveBeenCalledTimes(1); expect(listeners.b).not.toHaveBeenCalled(); expect(listeners.g).toHaveBeenCalledTimes(1); }) - .when('group "g" is updated with "b" added, same state', ({ context, listeners }) => { + .when('group "g" is updated with "b" added, still expanded', ({ context, listeners }) => { Object.values(listeners).forEach(listener => listener.mockClear()); - context.setLogicalGrouping('g', { activityKeys: ['a', 'b'], groupState: EXPANDED }); + context.setLogicalGrouping('g', { activityKeys: ['a', 'b'], isCollapsed: false }); }) .then('only "b" should be notified', ({ listeners }) => { expect(listeners.a).not.toHaveBeenCalled(); @@ -71,7 +68,7 @@ scenario( .when('group "g" collapses', ({ context, listeners }) => { Object.values(listeners).forEach(listener => listener.mockClear()); - context.setLogicalGrouping('g', { activityKeys: ['a', 'b'], groupState: COLLAPSED }); + context.setLogicalGrouping('g', { activityKeys: ['a', 'b'], isCollapsed: true }); }) .then('only "g" should be notified', ({ listeners }) => { expect(listeners.a).not.toHaveBeenCalled(); @@ -81,7 +78,7 @@ scenario( .when('"a" moves from group "g" to group "h"', ({ context, listeners }) => { Object.values(listeners).forEach(listener => listener.mockClear()); - context.setLogicalGrouping('h', { activityKeys: ['a'], groupState: EXPANDED }); + context.setLogicalGrouping('h', { activityKeys: ['a'], isCollapsed: false }); }) .then('only "a" should be notified, and its group key should read "h"', ({ context, listeners }) => { expect(listeners.a).toHaveBeenCalledTimes(1); diff --git a/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts b/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts index 3b16a344cc..0297a79052 100644 --- a/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts +++ b/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts @@ -5,7 +5,6 @@ import { object, optional, pipe, readonly, type InferInput } from 'valibot'; import createKeyedSubscription from '../../hooks/internal/keyedSubscription/createKeyedSubscription'; import ActivityLogicalGroupingContext, { type ActivityLogicalGroupingContextType, - type GroupState, type LogicalGrouping } from './private/ActivityLogicalGroupingContext'; @@ -23,7 +22,7 @@ const ActivityLogicalGroupingComposer = (props: ActivityLogicalGroupingComposerP const logicalGroupingsRef = useRef>(new Map()); const activityToGroupMapRef = useRef>(new Map()); - const [groupStateSubscription] = useState(() => createKeyedSubscription()); + const [groupIsCollapsedSubscription] = useState(() => createKeyedSubscription()); const [logicalGroupKeySubscription] = useState(() => createKeyedSubscription()); // Notifies only the activities whose group key changed. @@ -42,7 +41,7 @@ const ActivityLogicalGroupingComposer = (props: ActivityLogicalGroupingComposerP [activityToGroupMapRef, logicalGroupKeySubscription] ); - // Setting and removing notify `useGroupState()` and `useLogicalGroupKey()`, which re-render: call them from effects, never while rendering. + // Setting and removing notify `useGroupIsCollapsed()` and `useLogicalGroupKey()`, which re-render: call them from effects, never while rendering. const setLogicalGrouping = useCallback( (key: string, grouping: LogicalGrouping) => { const prevGrouping = logicalGroupingsRef.current.get(key); @@ -64,9 +63,9 @@ const ActivityLogicalGroupingComposer = (props: ActivityLogicalGroupingComposerP ) ); - prevGrouping?.groupState !== grouping.groupState && groupStateSubscription.notify([key]); + prevGrouping?.isCollapsed !== grouping.isCollapsed && groupIsCollapsedSubscription.notify([key]); }, - [activityToGroupMapRef, groupStateSubscription, logicalGroupingsRef, setActivityToGroupMap] + [activityToGroupMapRef, groupIsCollapsedSubscription, logicalGroupingsRef, setActivityToGroupMap] ); const removeLogicalGrouping = useCallback( @@ -74,9 +73,9 @@ const ActivityLogicalGroupingComposer = (props: ActivityLogicalGroupingComposerP logicalGroupingsRef.current.delete(key); setActivityToGroupMap(new Map([...activityToGroupMapRef.current].filter(([, value]) => value !== key))); - groupStateSubscription.notify([key]); + groupIsCollapsedSubscription.notify([key]); }, - [activityToGroupMapRef, groupStateSubscription, logicalGroupingsRef, setActivityToGroupMap] + [activityToGroupMapRef, groupIsCollapsedSubscription, logicalGroupingsRef, setActivityToGroupMap] ); const getLogicalGroupKey = useCallback( @@ -84,8 +83,8 @@ const ActivityLogicalGroupingComposer = (props: ActivityLogicalGroupingComposerP [activityToGroupMapRef] ); - const getGroupState = useCallback( - (groupKey: string): GroupState | undefined => logicalGroupingsRef.current.get(groupKey)?.groupState, + const getGroupIsCollapsed = useCallback( + (groupKey: string): boolean | undefined => logicalGroupingsRef.current.get(groupKey)?.isCollapsed, [logicalGroupingsRef] ); @@ -105,18 +104,18 @@ const ActivityLogicalGroupingComposer = (props: ActivityLogicalGroupingComposerP () => Object.freeze({ getGroupBoundaries, - getGroupState, + getGroupIsCollapsed, getLogicalGroupKey, removeLogicalGrouping, setLogicalGrouping, - subscribeGroupState: groupStateSubscription.subscribe, + subscribeGroupIsCollapsed: groupIsCollapsedSubscription.subscribe, subscribeLogicalGroupKey: logicalGroupKeySubscription.subscribe }), [ getGroupBoundaries, - getGroupState, + getGroupIsCollapsed, getLogicalGroupKey, - groupStateSubscription, + groupIsCollapsedSubscription, logicalGroupKeySubscription, removeLogicalGrouping, setLogicalGrouping diff --git a/packages/component/src/providers/ActivityLogicalGrouping/index.ts b/packages/component/src/providers/ActivityLogicalGrouping/index.ts index 9285a7b787..217ae4b092 100644 --- a/packages/component/src/providers/ActivityLogicalGrouping/index.ts +++ b/packages/component/src/providers/ActivityLogicalGrouping/index.ts @@ -1,7 +1,7 @@ export { default as ActivityLogicalGroupingComposer } from './ActivityLogicalGroupingComposer'; -export { default as useGroupState } from './useGroupState'; +export { default as useGroupIsCollapsed } from './useGroupIsCollapsed'; export { default as useLogicalGroupKey } from './useLogicalGroupKey'; export { default as useRemoveLogicalGrouping } from './useRemoveLogicalGrouping'; export { default as useSetLogicalGrouping } from './useSetLogicalGrouping'; export type { ActivityLogicalGroupingComposerProps } from './ActivityLogicalGroupingComposer'; -export type { LogicalGrouping, GroupState } from './private/ActivityLogicalGroupingContext'; +export type { LogicalGrouping } from './private/ActivityLogicalGroupingContext'; diff --git a/packages/component/src/providers/ActivityLogicalGrouping/private/ActivityLogicalGroupingContext.ts b/packages/component/src/providers/ActivityLogicalGrouping/private/ActivityLogicalGroupingContext.ts index 1a71ff14df..88005b1c52 100644 --- a/packages/component/src/providers/ActivityLogicalGrouping/private/ActivityLogicalGroupingContext.ts +++ b/packages/component/src/providers/ActivityLogicalGrouping/private/ActivityLogicalGroupingContext.ts @@ -1,23 +1,18 @@ import createContextAndHook from '../../createContextAndHook'; -type GroupState = { - readonly isCollapsed: boolean; -}; - type LogicalGrouping = { readonly activityKeys: readonly string[]; - /** Compared by identity: pass the same object until the state changes, so `useGroupState()` does not re-render. */ - readonly groupState?: GroupState | undefined; + readonly isCollapsed: boolean; }; type ActivityLogicalGroupingContextType = { readonly getGroupBoundaries: (groupKey: string) => [string | undefined, string | undefined]; - readonly getGroupState: (groupKey: string) => GroupState | undefined; + readonly getGroupIsCollapsed: (groupKey: string) => boolean | undefined; readonly getLogicalGroupKey: (activityKey: string) => string | undefined; readonly removeLogicalGrouping: (key: string) => void; /** Adds the logical grouping, or replaces the one with the same key. */ readonly setLogicalGrouping: (key: string, grouping: LogicalGrouping) => void; - readonly subscribeGroupState: (groupKey: string, listener: () => void) => () => void; + readonly subscribeGroupIsCollapsed: (groupKey: string, listener: () => void) => () => void; readonly subscribeLogicalGroupKey: (activityKey: string, listener: () => void) => () => void; }; @@ -29,6 +24,5 @@ export default contextComponentType; export { useContext as useActivityLogicalGroupingContext, type ActivityLogicalGroupingContextType, - type GroupState, type LogicalGrouping }; diff --git a/packages/component/src/providers/ActivityLogicalGrouping/private/useGetGroupIsCollapsed.ts b/packages/component/src/providers/ActivityLogicalGrouping/private/useGetGroupIsCollapsed.ts new file mode 100644 index 0000000000..ff071aaf46 --- /dev/null +++ b/packages/component/src/providers/ActivityLogicalGrouping/private/useGetGroupIsCollapsed.ts @@ -0,0 +1,11 @@ +import { useActivityLogicalGroupingContext } from './ActivityLogicalGroupingContext'; + +/** + * Returns a getter that reads whether the group is collapsed at call time, without subscribing. + * + * Only for keyboard navigation in `TranscriptFocusComposer`, which looks up arbitrary groups while handling a key press. + * Not exported from the provider: components that render from it must use `useGroupIsCollapsed()`. + */ +export default function useGetGroupIsCollapsed(): (groupKey: string) => boolean | undefined { + return useActivityLogicalGroupingContext().getGroupIsCollapsed; +} diff --git a/packages/component/src/providers/ActivityLogicalGrouping/private/useGetGroupState.ts b/packages/component/src/providers/ActivityLogicalGrouping/private/useGetGroupState.ts deleted file mode 100644 index c8c3bb14e1..0000000000 --- a/packages/component/src/providers/ActivityLogicalGrouping/private/useGetGroupState.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useActivityLogicalGroupingContext, type GroupState } from './ActivityLogicalGroupingContext'; - -/** - * Returns a getter that reads the latest group state at call time, without subscribing. - * - * Only for keyboard navigation in `TranscriptFocusComposer`, which looks up arbitrary groups while handling a key press. - * Not exported from the provider: components that render from the group state must use `useGroupState()`. - */ -export default function useGetGroupState(): (groupKey: string) => GroupState | undefined { - return useActivityLogicalGroupingContext().getGroupState; -} diff --git a/packages/component/src/providers/ActivityLogicalGrouping/useGroupState.spec.tsx b/packages/component/src/providers/ActivityLogicalGrouping/useGroupIsCollapsed.spec.tsx similarity index 83% rename from packages/component/src/providers/ActivityLogicalGrouping/useGroupState.spec.tsx rename to packages/component/src/providers/ActivityLogicalGrouping/useGroupIsCollapsed.spec.tsx index 7d213697fa..8f88504fb0 100644 --- a/packages/component/src/providers/ActivityLogicalGrouping/useGroupState.spec.tsx +++ b/packages/component/src/providers/ActivityLogicalGrouping/useGroupIsCollapsed.spec.tsx @@ -10,13 +10,12 @@ import { act } from 'react-dom/test-utils'; import usePartGroupingLogicalGroup from '../../Middleware/ActivityGrouping/ui/PartGrouping/private/usePartGroupingLogicalGroup'; import ActivityLogicalGroupingComposer from './ActivityLogicalGroupingComposer'; -import { type GroupState } from './private/ActivityLogicalGroupingContext'; -import useGroupState from './useGroupState'; +import useGroupIsCollapsed from './useGroupIsCollapsed'; -type Renders = (GroupState | undefined)[]; +type Renders = (boolean | undefined)[]; function Reader({ groupKey, renders }: { readonly groupKey: string; readonly renders: Renders }) { - renders.push(useGroupState(groupKey)[0]); + renders.push(useGroupIsCollapsed(groupKey)[0]); return null; } @@ -57,7 +56,7 @@ function renderGroups( } scenario( - 'useGroupState', + 'useGroupIsCollapsed', bdd => { bdd .given('an empty transcript', (): { container: HTMLElement; renders1: Renders; renders2: Renders } => ({ @@ -69,9 +68,9 @@ scenario( renderGroups(container, { isGroup1Collapsed: false, renders1, renders2 }) ) .then('both readers should see an expanded group', ({ renders1, renders2 }) => { - // Rendered before the group is added in a layout effect, then re-rendered with its state before paint. - expect(renders1).toStrictEqual([undefined, { isCollapsed: false }]); - expect(renders2).toStrictEqual([undefined, { isCollapsed: false }]); + // Rendered before the group is added in a layout effect, then re-rendered with the group before paint. + expect(renders1).toStrictEqual([undefined, false]); + expect(renders2).toStrictEqual([undefined, false]); }) .when('group 1 collapses', ({ container, renders1, renders2 }) => { renders1.splice(0); @@ -79,9 +78,7 @@ scenario( renderGroups(container, { isGroup1Collapsed: true, renders1, renders2 }); }) - .then('group 1 reader should re-render once, collapsed', ({ renders1 }) => - expect(renders1).toStrictEqual([{ isCollapsed: true }]) - ) + .then('group 1 reader should re-render once, collapsed', ({ renders1 }) => expect(renders1).toStrictEqual([true])) .and('group 2 reader should not re-render, its group did not change', ({ renders2 }) => expect(renders2).toStrictEqual([]) ) @@ -91,9 +88,7 @@ scenario( renderGroups(container, { isGroup1Collapsed: false, renders1, renders2 }); }) - .then('group 1 reader should re-render once, expanded', ({ renders1 }) => - expect(renders1).toStrictEqual([{ isCollapsed: false }]) - ) + .then('group 1 reader should re-render once, expanded', ({ renders1 }) => expect(renders1).toStrictEqual([false])) .and('group 2 reader should not re-render', ({ renders2 }) => expect(renders2).toStrictEqual([])); }, JestGlobals diff --git a/packages/component/src/providers/ActivityLogicalGrouping/useGroupIsCollapsed.ts b/packages/component/src/providers/ActivityLogicalGrouping/useGroupIsCollapsed.ts new file mode 100644 index 0000000000..eae7e0241d --- /dev/null +++ b/packages/component/src/providers/ActivityLogicalGrouping/useGroupIsCollapsed.ts @@ -0,0 +1,13 @@ +import useKeyedSubscriptionValue from '../../hooks/internal/keyedSubscription/useKeyedSubscriptionValue'; +import { useActivityLogicalGroupingContext } from './private/ActivityLogicalGroupingContext'; + +/** + * Returns whether the logical group is collapsed, or `undefined` if the group does not exist. + * + * The component re-renders only when this group collapses, expands, or is removed. + */ +export default function useGroupIsCollapsed(groupKey: string): readonly [boolean | undefined] { + const { getGroupIsCollapsed, subscribeGroupIsCollapsed } = useActivityLogicalGroupingContext(); + + return useKeyedSubscriptionValue(subscribeGroupIsCollapsed, groupKey, getGroupIsCollapsed); +} diff --git a/packages/component/src/providers/ActivityLogicalGrouping/useGroupState.ts b/packages/component/src/providers/ActivityLogicalGrouping/useGroupState.ts deleted file mode 100644 index c831acbc3e..0000000000 --- a/packages/component/src/providers/ActivityLogicalGrouping/useGroupState.ts +++ /dev/null @@ -1,13 +0,0 @@ -import useKeyedSubscriptionValue from '../../hooks/internal/keyedSubscription/useKeyedSubscriptionValue'; -import { useActivityLogicalGroupingContext, type GroupState } from './private/ActivityLogicalGroupingContext'; - -/** - * Returns the state of the logical group, such as whether it is collapsed, or `undefined` if the group does not exist or has no state. - * - * The component re-renders only when the state of this group changes. - */ -export default function useGroupState(groupKey: string): readonly [GroupState | undefined] { - const { getGroupState, subscribeGroupState } = useActivityLogicalGroupingContext(); - - return useKeyedSubscriptionValue(subscribeGroupState, groupKey, getGroupState); -} diff --git a/packages/component/src/providers/ActivityLogicalGrouping/useLogicalGroupKey.spec.tsx b/packages/component/src/providers/ActivityLogicalGrouping/useLogicalGroupKey.spec.tsx index ef5825aea1..cf9925e0a0 100644 --- a/packages/component/src/providers/ActivityLogicalGrouping/useLogicalGroupKey.spec.tsx +++ b/packages/component/src/providers/ActivityLogicalGrouping/useLogicalGroupKey.spec.tsx @@ -18,7 +18,7 @@ function Group({ activityKeys }: { readonly activityKeys: string[] }) { const setLogicalGrouping = useSetLogicalGrouping(); useLayoutEffect(() => { - setLogicalGrouping('g', { activityKeys }); + setLogicalGrouping('g', { activityKeys, isCollapsed: false }); }, [activityKeys, setLogicalGrouping]); return null; diff --git a/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.groupState.spec.tsx b/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.groupIsCollapsed.spec.tsx similarity index 97% rename from packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.groupState.spec.tsx rename to packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.groupIsCollapsed.spec.tsx index 809233e54d..a9068c8c2a 100644 --- a/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.groupState.spec.tsx +++ b/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.groupIsCollapsed.spec.tsx @@ -61,7 +61,7 @@ function getFocusedId({ contexts }: Result): string | undefined { } scenario( - 'TranscriptFocusComposer reads the latest group state while handling a key press', + 'TranscriptFocusComposer reads whether the group is collapsed while handling a key press', bdd => { bdd .given('a transcript with an expanded part group followed by activity "c"', () => { diff --git a/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.spec.tsx b/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.spec.tsx index 2f37387f57..ab25d027b4 100644 --- a/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.spec.tsx +++ b/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.spec.tsx @@ -71,7 +71,7 @@ describe('TranscriptFocusComposer - focusRelativeActivity', () => { for (const [activityKey, groupKey] of Object.entries(config.groupKeyMappings)) { const group = groups.get(groupKey) ?? { activityKeys: [], - groupState: config.groupStates[groupKey] + isCollapsed: !!config.groupStates[groupKey]?.isCollapsed }; group.activityKeys.push(activityKey); diff --git a/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.tsx b/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.tsx index 88babb00e9..d6322e842e 100644 --- a/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.tsx +++ b/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.tsx @@ -7,7 +7,7 @@ import usePrevious from '../../hooks/internal/usePrevious'; import useStateRef from '../../hooks/internal/useStateRef'; import useValueRef from '../../hooks/internal/useValueRef'; import scrollIntoViewWithBlockNearest from '../../Utils/scrollIntoViewWithBlockNearest'; -import useGetGroupState from '../ActivityLogicalGrouping/private/useGetGroupState'; +import useGetGroupIsCollapsed from '../ActivityLogicalGrouping/private/useGetGroupIsCollapsed'; import useGetLogicalGroupKey from '../ActivityLogicalGrouping/private/useGetLogicalGroupKey'; import useGetLogicalGroupBoundaries from '../ActivityLogicalGrouping/private/useGetLogicalGroupBoundaries'; import useRenderingActivityKeys from '../RenderingActivities/useRenderingActivityKeys'; @@ -31,10 +31,10 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp const [renderingActivityKeys] = useRenderingActivityKeys(); const [, setRawFocusedActivityKey, rawFocusedActivityKeyRef] = useStateRef(); // Keyboard navigation looks up arbitrary activities and groups while handling a key press, and nothing here renders from them. - // Thus, read them at call time instead of subscribing with `useLogicalGroupKey()` and `useGroupState()`. + // Thus, read them at call time instead of subscribing with `useLogicalGroupKey()` and `useGroupIsCollapsed()`. const getGroupBoundaries = useGetLogicalGroupBoundaries(); const getGroupKeyByActivityKey = useGetLogicalGroupKey(); - const getGroupState = useGetGroupState(); + const getGroupIsCollapsed = useGetGroupIsCollapsed(); // As we need to use IDREF for `aria-activedescendant`, // this prefix will differentiate multiple instances of transcript on the same page. @@ -130,11 +130,10 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp } const groupKey = typeof activityKey === 'string' && getGroupKeyByActivityKey(activityKey); - const groupState = groupKey && getGroupState(groupKey); // shortcut for collapsed group // when collapsed, we do not want to focus on the group or scroll it into view - if (groupState?.isCollapsed) { + if (groupKey && getGroupIsCollapsed(groupKey)) { return; } @@ -156,8 +155,8 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp focusByGroupKey, focusedActivityKeyRef, getDescendantIdByActivityKey, + getGroupIsCollapsed, getGroupKeyByActivityKey, - getGroupState, handleFocus, rawFocusedActivityKeyRef, renderingActivityKeysRef, @@ -192,11 +191,10 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp (direction: 'up' | 'down') => { const { current: currentGroupKey } = rawFocusedActivityGroupKeyRef; const { current: orderedActivityKeys } = renderingActivityKeysRef; - const currentGroupState = getGroupState(currentGroupKey); const [firstActivityKey, lastActivityKey] = getGroupBoundaries(currentGroupKey); if (direction === 'down') { - if (!currentGroupState?.isCollapsed) { + if (!getGroupIsCollapsed(currentGroupKey)) { // From expanded group header, go to first activity in the group if (firstActivityKey) { focusByActivityKey(firstActivityKey, true); @@ -224,11 +222,9 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp if (currentIndex !== -1) { const prevActivityKey = orderedActivityKeys[currentIndex - 1]; const prevGroupKey = getGroupKeyByActivityKey(prevActivityKey); - const prevGroupState = - prevGroupKey && prevGroupKey !== currentGroupKey ? getGroupState(prevGroupKey) : undefined; // Previous activity is in a different collapsed group - if (prevGroupState?.isCollapsed) { + if (prevGroupKey && prevGroupKey !== currentGroupKey && getGroupIsCollapsed(prevGroupKey)) { focusByActivityKey(prevActivityKey, false); focusByGroupKey(prevGroupKey); } else { @@ -241,8 +237,8 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp focusByActivityKey, focusByGroupKey, getGroupBoundaries, + getGroupIsCollapsed, getGroupKeyByActivityKey, - getGroupState, rawFocusedActivityGroupKeyRef, renderingActivityKeysRef ] @@ -262,13 +258,12 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp // Moving to a different group const [firstActivityKey, lastActivityKey] = getGroupBoundaries(nextGroupKey); - const nextGroupState = getGroupState(nextGroupKey); if (direction === 'down' && firstActivityKey === nextActivityKey) { // Going down into a new group - always focus header first focusByActivityKey(currentActivityKey, false); focusByGroupKey(nextGroupKey); - } else if (direction === 'up' && lastActivityKey === nextActivityKey && nextGroupState?.isCollapsed) { + } else if (direction === 'up' && lastActivityKey === nextActivityKey && getGroupIsCollapsed(nextGroupKey)) { // Going up into a collapsed group from below - focus header focusByGroupKey(nextGroupKey); } else { @@ -276,7 +271,7 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp focusByActivityKey(nextActivityKey, true); } }, - [focusByActivityKey, focusByGroupKey, getGroupBoundaries, getGroupKeyByActivityKey, getGroupState] + [focusByActivityKey, focusByGroupKey, getGroupBoundaries, getGroupIsCollapsed, getGroupKeyByActivityKey] ); // Helper function to handle delta > 1 navigation @@ -345,14 +340,14 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp // Handle navigation from a focused activity const groupKey = getGroupKeyByActivityKey(focusedActivityKey); const [firstActivityKey, lastActivityKey] = getGroupBoundaries(groupKey); - const groupState = groupKey ? getGroupState(groupKey) : undefined; + const isGroupCollapsed = !!groupKey && getGroupIsCollapsed(groupKey); // In group special cases if (groupKey) { if (direction === 'up') { // For expanded groups: only focus header when going up from first activity // For collapsed groups: focus header when going up from any activity in the group - if ((firstActivityKey === focusedActivityKey && !groupState?.isCollapsed) || groupState?.isCollapsed) { + if ((firstActivityKey === focusedActivityKey && !isGroupCollapsed) || isGroupCollapsed) { // From first activity in expanded group OR any activity in collapsed group, go to group header focusByActivityKey(focusedActivityKey, false); focusByGroupKey(groupKey); @@ -361,7 +356,7 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp } else if (direction === 'down') { // Special handling for collapsed groups: when moving down from any activity in a collapsed group, // jump to the next visible element outside the group - if (groupState?.isCollapsed) { + if (isGroupCollapsed) { const nextActivityKey = getNextActivityLinear(lastActivityKey, direction); return handleGroupTransition(lastActivityKey, nextActivityKey, direction); } @@ -380,8 +375,8 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp focusByGroupKey, focusedActivityKeyRef, getGroupBoundaries, + getGroupIsCollapsed, getGroupKeyByActivityKey, - getGroupState, getNextActivityLinear, handleDeltaNavigation, handleGroupTransition, From 6adb788cf0df242abbc989defa54b1e4e61be8b0 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 22 Oct 2026 18:35:19 -0700 Subject: [PATCH 15/18] useKeyedSubscriptionValue: keep the key with the value in the state, 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. --- .../useKeyedSubscriptionValue.spec.tsx | 51 ++++++++++++++++++- .../useKeyedSubscriptionValue.ts | 14 +++-- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/packages/component/src/hooks/internal/keyedSubscription/useKeyedSubscriptionValue.spec.tsx b/packages/component/src/hooks/internal/keyedSubscription/useKeyedSubscriptionValue.spec.tsx index b0af1ee075..8de7d058b3 100644 --- a/packages/component/src/hooks/internal/keyedSubscription/useKeyedSubscriptionValue.spec.tsx +++ b/packages/component/src/hooks/internal/keyedSubscription/useKeyedSubscriptionValue.spec.tsx @@ -47,6 +47,17 @@ function SetValueBeforeSubscribe({ store }: { readonly store: Store }) { return null; } +// Placed before ``: its layout effect runs after the old subscription is cleaned up and before `` subscribes. +// Changes "b" to the value of "a" and notifies "b", which nobody subscribed to yet. +function SetBToAOnMount({ store }: { readonly store: Store }) { + useLayoutEffect(() => { + store.values.set('b', store.values.get('a')); + store.subscription.notify(['b']); + }, [store]); + + return null; +} + scenario( 'useKeyedSubscriptionValue', bdd => { @@ -131,7 +142,7 @@ scenario( }) .then('the reader should render "2" from its first render with "b"', ({ store }) => { expect(store.renders.a).toStrictEqual(['1']); - // Re-renders once: its state holds "1" until the re-check after subscribing to "b". + // Re-renders once: its state holds "a" until the re-check after subscribing to "b". expect(store.renders.b).toStrictEqual(['2', '2']); }) .when('"a" changes and is notified', ({ store }) => { @@ -151,6 +162,44 @@ scenario( expect(store.renders.b).toStrictEqual(['2', '2', '4']) ); + bdd + .given('a reader of "a", "a" is "1" and "b" is "2"', () => { + const container = document.createElement('div'); + const store = createStore(); + + store.values.set('a', '1'); + store.values.set('b', '2'); + + // `{false}` keeps the reader at the same position as in the next render, so it updates instead of remounting. + act(() => { + render( + + {false} + + , + container + ); + }); + + return { container, store }; + }) + .when('the reader switches to "b" as "b" changes to "1" before it subscribes', ({ container, store }) => { + act(() => { + render( + + + + , + container + ); + }); + }) + .then('the reader should re-render with "1", the new value of "b"', ({ store }) => { + expect(store.values.get('b')).toBe('1'); + // Rendered "2" first, then "1" after the re-check, although "1" is also the last value of "a". + expect(store.renders.b).toStrictEqual(['2', '1']); + }); + bdd .given('a reader of a value which is a function', () => { const container = document.createElement('div'); diff --git a/packages/component/src/hooks/internal/keyedSubscription/useKeyedSubscriptionValue.ts b/packages/component/src/hooks/internal/keyedSubscription/useKeyedSubscriptionValue.ts index 95e9aff4c2..eb8f0b79e4 100644 --- a/packages/component/src/hooks/internal/keyedSubscription/useKeyedSubscriptionValue.ts +++ b/packages/component/src/hooks/internal/keyedSubscription/useKeyedSubscriptionValue.ts @@ -14,12 +14,18 @@ export default function useKeyedSubscriptionValue( ): readonly [T] { const value = getValue(key); - // The state only triggers re-renders, React skips them when the value did not change. - // Function forms: React would call a function `T` as an initializer or updater. - const [, setValue] = useState(() => value); + // The state only triggers re-renders, React skips them when the same tuple is returned. + // It holds the key with the value: after a key switch, the state still holds the previous key, so the re-check always re-renders, + // even if the new key's value has just changed to the previous key's value. + const [, setState] = useState(() => Object.freeze([key, value] as const)); useLayoutEffect(() => { - const handleChange = () => setValue(() => getValue(key)); + const handleChange = () => + setState(state => { + const nextValue = getValue(key); + + return state[0] === key && state[1] === nextValue ? state : Object.freeze([key, nextValue] as const); + }); // Catches changes between render and subscription. handleChange(); From 6af523a75d9041ca2637dc04d6c30df72bb7a24d Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 22 Oct 2026 18:37:23 -0700 Subject: [PATCH 16/18] useGroupDescendantIdByGroupKey: subscribe by group key instead of relying 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(). --- .../createKeyedSubscription.spec.ts | 8 +- .../createKeyedSubscription.ts | 22 +-- .../TranscriptFocusComposer.tsx | 26 +++- .../TranscriptFocus/private/Context.ts | 1 + .../useGroupDescendantIdByGroupKey.spec.tsx | 132 ++++++++++++++++++ .../useGroupDescendantIdByGroupKey.ts | 14 +- 6 files changed, 182 insertions(+), 21 deletions(-) create mode 100644 packages/component/src/providers/TranscriptFocus/useGroupDescendantIdByGroupKey.spec.tsx diff --git a/packages/component/src/hooks/internal/keyedSubscription/createKeyedSubscription.spec.ts b/packages/component/src/hooks/internal/keyedSubscription/createKeyedSubscription.spec.ts index 1931a3f3c5..42031361b7 100644 --- a/packages/component/src/hooks/internal/keyedSubscription/createKeyedSubscription.spec.ts +++ b/packages/component/src/hooks/internal/keyedSubscription/createKeyedSubscription.spec.ts @@ -8,7 +8,7 @@ scenario( bdd => { bdd .given('listeners subscribed to "a" and "b"', () => { - const { notify, subscribe } = createKeyedSubscription(); + const { notify, notifyAll, subscribe } = createKeyedSubscription(); const listenerA = JestGlobals.jest.fn(); const listenerB = JestGlobals.jest.fn(); @@ -16,6 +16,7 @@ scenario( listenerA, listenerB, notify, + notifyAll, unsubscribeA: subscribe('a', listenerA), unsubscribeB: subscribe('b', listenerB) }; @@ -32,6 +33,11 @@ scenario( .then('only the listener of "b" should be called', ({ listenerA, listenerB }) => { expect(listenerA).toHaveBeenCalledTimes(1); // From the first notification. expect(listenerB).toHaveBeenCalledTimes(1); + }) + .when('all keys are notified', ({ notifyAll }) => notifyAll()) + .then('only the listener still subscribed should be called', ({ listenerA, listenerB }) => { + expect(listenerA).toHaveBeenCalledTimes(1); + expect(listenerB).toHaveBeenCalledTimes(2); }); }, JestGlobals diff --git a/packages/component/src/hooks/internal/keyedSubscription/createKeyedSubscription.ts b/packages/component/src/hooks/internal/keyedSubscription/createKeyedSubscription.ts index 552e9a0a46..4984ba4c0a 100644 --- a/packages/component/src/hooks/internal/keyedSubscription/createKeyedSubscription.ts +++ b/packages/component/src/hooks/internal/keyedSubscription/createKeyedSubscription.ts @@ -1,6 +1,8 @@ type KeyedSubscription = { /** Calls the listeners of each key. Call it after commit (e.g. from an effect), as listeners may re-render. */ readonly notify: (keys: Iterable) => void; + /** Calls the listeners of every subscribed key, for a change that affects all keys. Call it after commit, as `notify()`. */ + readonly notifyAll: () => void; /** Calls the listener when the key is notified, until unsubscribed. */ readonly subscribe: (key: K, listener: () => void) => () => void; }; @@ -9,15 +11,19 @@ type KeyedSubscription = { export default function createKeyedSubscription(): KeyedSubscription { const listenersByKey = new Map void>>(); - return Object.freeze({ - notify: (keys: Iterable) => { - for (const key of new Set(keys)) { - // Copies the listeners: a listener may unsubscribe while being notified. - for (const listener of Array.from(listenersByKey.get(key) ?? [])) { - listener(); - } + const notify = (keys: Iterable) => { + for (const key of new Set(keys)) { + // Copies the listeners: a listener may unsubscribe while being notified. + for (const listener of Array.from(listenersByKey.get(key) ?? [])) { + listener(); } - }, + } + }; + + return Object.freeze({ + notify, + // Copies the keys: a listener may subscribe or unsubscribe while being notified. + notifyAll: () => notify(Array.from(listenersByKey.keys())), subscribe: (key: K, listener: () => void) => { const listeners = listenersByKey.get(key) ?? new Set(); diff --git a/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.tsx b/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.tsx index d6322e842e..2a040e17c5 100644 --- a/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.tsx +++ b/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.tsx @@ -1,8 +1,17 @@ /* eslint-disable no-magic-numbers */ import random from 'math-random'; import PropTypes from 'prop-types'; -import React, { memo, useCallback, useMemo, type MutableRefObject, type ReactNode } from 'react'; - +import React, { + memo, + useCallback, + useLayoutEffect, + useMemo, + useState, + type MutableRefObject, + type ReactNode +} from 'react'; + +import createKeyedSubscription from '../../hooks/internal/keyedSubscription/createKeyedSubscription'; import usePrevious from '../../hooks/internal/usePrevious'; import useStateRef from '../../hooks/internal/useStateRef'; import useValueRef from '../../hooks/internal/useValueRef'; @@ -50,6 +59,15 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp [prefix] ); + const [groupDescendantIdSubscription] = useState(() => createKeyedSubscription()); + + // Notifies `useGroupDescendantIdByGroupKey()` of every group when the getter's output can change, i.e. when `prefix` changes. + // Today, `prefix` is per instance, so this only runs on mount, when the subscribers already have the current ID. + useLayoutEffect( + () => groupDescendantIdSubscription.notifyAll(), + [getGroupDescendantIdByGroupKey, groupDescendantIdSubscription] + ); + const renderingActivityKeysRef = useValueRef(renderingActivityKeys); // While the transcript or any descendants are not focused, if the transcript is updated, reset the user-selected active descendant. @@ -397,7 +415,8 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp focusedGroupKeyState: Object.freeze([rawFocusedActivityGroupKey]) as readonly [string], focusRelativeActivity, getDescendantIdByActivityKey, - getGroupDescendantIdByGroupKey + getGroupDescendantIdByGroupKey, + subscribeGroupDescendantId: groupDescendantIdSubscription.subscribe }), [ activeDescendantId, @@ -408,6 +427,7 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp focusRelativeActivity, getDescendantIdByActivityKey, getGroupDescendantIdByGroupKey, + groupDescendantIdSubscription, rawFocusedActivityGroupKey, rawFocusedActivityKey ] diff --git a/packages/component/src/providers/TranscriptFocus/private/Context.ts b/packages/component/src/providers/TranscriptFocus/private/Context.ts index e5633dcfca..c4a496727c 100644 --- a/packages/component/src/providers/TranscriptFocus/private/Context.ts +++ b/packages/component/src/providers/TranscriptFocus/private/Context.ts @@ -11,6 +11,7 @@ type TranscriptFocusContextType = { focusRelativeActivity: (delta: number) => void; getDescendantIdByActivityKey: (activityKey?: string) => string | undefined; getGroupDescendantIdByGroupKey: (groupKey?: string) => string | undefined; + subscribeGroupDescendantId: (groupKey: string | undefined, listener: () => void) => () => void; }; const TranscriptFocusContext = createContext(undefined); diff --git a/packages/component/src/providers/TranscriptFocus/useGroupDescendantIdByGroupKey.spec.tsx b/packages/component/src/providers/TranscriptFocus/useGroupDescendantIdByGroupKey.spec.tsx new file mode 100644 index 0000000000..5c171dcb04 --- /dev/null +++ b/packages/component/src/providers/TranscriptFocus/useGroupDescendantIdByGroupKey.spec.tsx @@ -0,0 +1,132 @@ +/** @jest-environment @happy-dom/jest-environment */ + +import 'core-js/features/set/index.js'; + +import * as JestGlobals from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; +import React, { Fragment, memo } from 'react'; +import { render } from 'react-dom'; +import { act } from 'react-dom/test-utils'; + +import createKeyedSubscription from '../../hooks/internal/keyedSubscription/createKeyedSubscription'; +import { ActivityLogicalGroupingComposer } from '../ActivityLogicalGrouping'; +import useRenderingActivityKeys from '../RenderingActivities/useRenderingActivityKeys'; +import TranscriptFocusContext, { type TranscriptFocusContextType } from './private/Context'; +import TranscriptFocusComposer from './TranscriptFocusComposer'; +import useGroupDescendantIdByGroupKey from './useGroupDescendantIdByGroupKey'; + +jest.mock('../RenderingActivities/useRenderingActivityKeys'); + +// Every render pushes its value, by group key. +type Renders = Map; + +function Reader({ groupKey, renders }: { readonly groupKey: string | undefined; readonly renders: Renders }) { + const [id] = useGroupDescendantIdByGroupKey(groupKey); + + renders.set(groupKey, [...(renders.get(groupKey) ?? []), id]); + + return null; +} + +// Memoized like activities in the transcript, so only its subscription or the context can re-render it. +const MemoizedReader = memo(Reader); + +function Readers({ renders }: { readonly renders: Renders }) { + return ( + + + + + + ); +} + +scenario( + 'useGroupDescendantIdByGroupKey under ', + bdd => { + bdd + .given('an empty transcript', () => { + jest.mocked(useRenderingActivityKeys).mockReturnValue(Object.freeze([Object.freeze([])])); + + return { container: document.createElement('div'), renders: new Map() as Renders }; + }) + .when('readers of "g1", "g2", and no group render', ({ container, renders }) => { + act(() => { + render( + + + + + , + container + ); + }); + }) + .then('each reader should render its group ID once', ({ renders }) => { + // The composer notifies every group on mount, which does not re-render: the IDs did not change. + expect(renders.get('g1')).toEqual([expect.stringMatching(/^webchat__transcript-focus-[^_]+__group-g1$/u)]); + expect(renders.get('g2')).toEqual([expect.stringMatching(/^webchat__transcript-focus-[^_]+__group-g2$/u)]); + }) + .and('the reader without a group key should render undefined', ({ renders }) => + expect(renders.get(undefined)).toStrictEqual([undefined]) + ); + }, + JestGlobals +); + +scenario( + 'useGroupDescendantIdByGroupKey with a notified change', + bdd => { + bdd + .given('readers of "g1", "g2", and no group, with a context which notifies changed IDs', () => { + const container = document.createElement('div'); + const ids = new Map([ + ['g1', 'id-1'], + ['g2', 'id-2'] + ]); + const renders: Renders = new Map(); + const subscription = createKeyedSubscription(); + + // Only what the hook reads: the getter and the subscription. + const context = Object.freeze({ + getGroupDescendantIdByGroupKey: (groupKey?: string) => ids.get(groupKey), + subscribeGroupDescendantId: subscription.subscribe + }) satisfies Pick< + TranscriptFocusContextType, + 'getGroupDescendantIdByGroupKey' | 'subscribeGroupDescendantId' + > as TranscriptFocusContextType; + + act(() => { + render( + + + , + container + ); + }); + + return { ids, renders, subscription }; + }) + .when('the ID of "g2" changes and every group is notified', ({ ids, subscription }) => { + ids.set('g2', 'id-2b'); + + act(() => subscription.notifyAll()); + }) + .then('only the reader of "g2" should re-render, with the new ID', ({ renders }) => { + expect(renders.get('g1')).toStrictEqual(['id-1']); + expect(renders.get('g2')).toStrictEqual(['id-2', 'id-2b']); + expect(renders.get(undefined)).toStrictEqual([undefined]); + }) + .when('the ID of "g1" changes and only "g1" is notified', ({ ids, subscription }) => { + ids.set('g1', 'id-1b'); + + act(() => subscription.notify(['g1'])); + }) + .then('only the reader of "g1" should re-render, with the new ID', ({ renders }) => { + expect(renders.get('g1')).toStrictEqual(['id-1', 'id-1b']); + expect(renders.get('g2')).toStrictEqual(['id-2', 'id-2b']); + expect(renders.get(undefined)).toStrictEqual([undefined]); + }); + }, + JestGlobals +); diff --git a/packages/component/src/providers/TranscriptFocus/useGroupDescendantIdByGroupKey.ts b/packages/component/src/providers/TranscriptFocus/useGroupDescendantIdByGroupKey.ts index 28428f94d6..beec8ab0ea 100644 --- a/packages/component/src/providers/TranscriptFocus/useGroupDescendantIdByGroupKey.ts +++ b/packages/component/src/providers/TranscriptFocus/useGroupDescendantIdByGroupKey.ts @@ -1,17 +1,13 @@ -import { useMemo } from 'react'; - +import useKeyedSubscriptionValue from '../../hooks/internal/keyedSubscription/useKeyedSubscriptionValue'; import useTranscriptFocusContext from './private/useContext'; /** - * Returns the ID of the group's active descendant element, for `aria-activedescendant`. + * Returns the ID of the group's active descendant element, for `aria-activedescendant`, or `undefined` without a group key. * - * The ID is derived from the group key and the transcript instance, so the component re-renders when either changes. + * Re-renders when `` notifies that the ID of this group changed. */ export default function useGroupDescendantIdByGroupKey(groupKey?: string | undefined): readonly [string | undefined] { - const { getGroupDescendantIdByGroupKey } = useTranscriptFocusContext(); + const { getGroupDescendantIdByGroupKey, subscribeGroupDescendantId } = useTranscriptFocusContext(); - return useMemo( - () => Object.freeze([getGroupDescendantIdByGroupKey(groupKey)] as const), - [getGroupDescendantIdByGroupKey, groupKey] - ); + return useKeyedSubscriptionValue(subscribeGroupDescendantId, groupKey, getGroupDescendantIdByGroupKey); } From 14e31ea565acb1b9129dc1fe4ba0e0d00cacd31a Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 22 Oct 2026 20:59:41 -0700 Subject: [PATCH 17/18] Simplify logical grouping to a keyed store --- .../private/usePartGroupingLogicalGroup.ts | 6 +- .../src/hooks/internal/createKeyedStore.ts | 38 +++ .../createKeyedSubscription.spec.ts | 73 ------ .../createKeyedSubscription.ts | 43 ---- .../useKeyedSubscriptionValue.spec.tsx | 237 ------------------ .../useKeyedSubscriptionValue.ts | 37 --- .../internal/useKeyedStoreValue.spec.tsx | 163 ++++++++++++ .../src/hooks/internal/useKeyedStoreValue.ts | 33 +++ .../ActivityLogicalGroupingComposer.spec.tsx | 103 -------- .../ActivityLogicalGroupingComposer.ts | 115 ++------- .../ActivityLogicalGrouping/index.ts | 1 - .../private/ActivityLogicalGroupingContext.ts | 10 +- .../private/useGetGroupIsCollapsed.ts | 11 - .../private/useGetLogicalGroupBoundaries.ts | 10 - .../private/useGetLogicalGroupKey.ts | 11 - .../useGroupIsCollapsed.spec.tsx | 95 ------- .../useGroupIsCollapsed.ts | 13 - .../useLogicalGroupKey.spec.tsx | 51 ++-- .../useLogicalGroupKey.ts | 12 +- ...iptFocusComposer.groupIsCollapsed.spec.tsx | 2 - .../TranscriptFocusComposer.tsx | 81 +++--- .../TranscriptFocus/private/Context.ts | 4 +- .../private/getGroupDescendantId.ts | 3 + .../useGroupDescendantIdByGroupKey.spec.tsx | 132 ---------- .../useGroupDescendantIdByGroupKey.ts | 15 +- 25 files changed, 354 insertions(+), 945 deletions(-) create mode 100644 packages/component/src/hooks/internal/createKeyedStore.ts delete mode 100644 packages/component/src/hooks/internal/keyedSubscription/createKeyedSubscription.spec.ts delete mode 100644 packages/component/src/hooks/internal/keyedSubscription/createKeyedSubscription.ts delete mode 100644 packages/component/src/hooks/internal/keyedSubscription/useKeyedSubscriptionValue.spec.tsx delete mode 100644 packages/component/src/hooks/internal/keyedSubscription/useKeyedSubscriptionValue.ts create mode 100644 packages/component/src/hooks/internal/useKeyedStoreValue.spec.tsx create mode 100644 packages/component/src/hooks/internal/useKeyedStoreValue.ts delete mode 100644 packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.spec.tsx delete mode 100644 packages/component/src/providers/ActivityLogicalGrouping/private/useGetGroupIsCollapsed.ts delete mode 100644 packages/component/src/providers/ActivityLogicalGrouping/private/useGetLogicalGroupBoundaries.ts delete mode 100644 packages/component/src/providers/ActivityLogicalGrouping/private/useGetLogicalGroupKey.ts delete mode 100644 packages/component/src/providers/ActivityLogicalGrouping/useGroupIsCollapsed.spec.tsx delete mode 100644 packages/component/src/providers/ActivityLogicalGrouping/useGroupIsCollapsed.ts create mode 100644 packages/component/src/providers/TranscriptFocus/private/getGroupDescendantId.ts delete mode 100644 packages/component/src/providers/TranscriptFocus/useGroupDescendantIdByGroupKey.spec.tsx diff --git a/packages/component/src/Middleware/ActivityGrouping/ui/PartGrouping/private/usePartGroupingLogicalGroup.ts b/packages/component/src/Middleware/ActivityGrouping/ui/PartGrouping/private/usePartGroupingLogicalGroup.ts index b201c75053..866c54f494 100644 --- a/packages/component/src/Middleware/ActivityGrouping/ui/PartGrouping/private/usePartGroupingLogicalGroup.ts +++ b/packages/component/src/Middleware/ActivityGrouping/ui/PartGrouping/private/usePartGroupingLogicalGroup.ts @@ -14,9 +14,9 @@ function usePartGroupingLogicalGroup({ activityKeys, isCollapsed }: UsePartGroup const removeLogicalGrouping = useRemoveLogicalGrouping(); const setLogicalGrouping = useSetLogicalGrouping(); - // Sets the group in an effect, not while rendering: Strict Mode runs the cleanup below, then runs this effect again. - // A layout effect, so activities using `useLogicalGroupKey()` or `useGroupIsCollapsed()` re-render before the browser paints. - // Collapsing or expanding the group updates it here, which notifies `useGroupIsCollapsed()`. + // In layout effects, not while rendering: setting re-renders the activities before paint. + // Strict Mode runs the cleanup below, then this effect again. + // Removed in its own effect, so a change of `activityKeys` updates the group instead of removing and adding it. useLayoutEffect(() => { setLogicalGrouping(partGroupKey, { activityKeys: Object.freeze(Array.from(activityKeys)), isCollapsed }); }, [activityKeys, isCollapsed, partGroupKey, setLogicalGrouping]); diff --git a/packages/component/src/hooks/internal/createKeyedStore.ts b/packages/component/src/hooks/internal/createKeyedStore.ts new file mode 100644 index 0000000000..5a2454d4c0 --- /dev/null +++ b/packages/component/src/hooks/internal/createKeyedStore.ts @@ -0,0 +1,38 @@ +type ReadonlyKeyedStore = { + readonly get: (key: string) => T | undefined; + /** Calls the listener after the value of the key changed, until unsubscribed. */ + readonly subscribe: (key: string, listener: () => void) => () => void; +}; + +type KeyedStore = ReadonlyKeyedStore & { + readonly delete: (key: string) => void; + /** Notifies the listeners of the key if the value changed. Listeners may re-render: call it after commit, e.g. from an effect. */ + readonly set: (key: string, value: T | undefined) => void; +}; + +/** Creates a store of values by key, which notifies only the listeners of keys whose value changed. */ +export default function createKeyedStore(): KeyedStore { + const eventTarget = new EventTarget(); + const values = new Map(); + + const set = (key: string, value: T | undefined): void => { + if (!Object.is(values.get(key), value)) { + typeof value === 'undefined' ? values.delete(key) : values.set(key, value); + + eventTarget.dispatchEvent(new Event(key)); + } + }; + + return Object.freeze({ + delete: (key: string) => set(key, undefined), + get: (key: string) => values.get(key), + set, + subscribe: (key: string, listener: () => void) => { + eventTarget.addEventListener(key, listener); + + return () => eventTarget.removeEventListener(key, listener); + } + }); +} + +export { type KeyedStore, type ReadonlyKeyedStore }; diff --git a/packages/component/src/hooks/internal/keyedSubscription/createKeyedSubscription.spec.ts b/packages/component/src/hooks/internal/keyedSubscription/createKeyedSubscription.spec.ts deleted file mode 100644 index 42031361b7..0000000000 --- a/packages/component/src/hooks/internal/keyedSubscription/createKeyedSubscription.spec.ts +++ /dev/null @@ -1,73 +0,0 @@ -import * as JestGlobals from '@jest/globals'; -import { scenario } from '@testduet/given-when-then'; - -import createKeyedSubscription from './createKeyedSubscription'; - -scenario( - 'createKeyedSubscription', - bdd => { - bdd - .given('listeners subscribed to "a" and "b"', () => { - const { notify, notifyAll, subscribe } = createKeyedSubscription(); - const listenerA = JestGlobals.jest.fn(); - const listenerB = JestGlobals.jest.fn(); - - return { - listenerA, - listenerB, - notify, - notifyAll, - unsubscribeA: subscribe('a', listenerA), - unsubscribeB: subscribe('b', listenerB) - }; - }) - .when('"a" is notified twice in one call', ({ notify }) => notify(['a', 'a'])) - .then('only the listener of "a" should be called, once', ({ listenerA, listenerB }) => { - expect(listenerA).toHaveBeenCalledTimes(1); - expect(listenerB).not.toHaveBeenCalled(); - }) - .when('the listener of "a" unsubscribes and both keys are notified', ({ notify, unsubscribeA }) => { - unsubscribeA(); - notify(['a', 'b']); - }) - .then('only the listener of "b" should be called', ({ listenerA, listenerB }) => { - expect(listenerA).toHaveBeenCalledTimes(1); // From the first notification. - expect(listenerB).toHaveBeenCalledTimes(1); - }) - .when('all keys are notified', ({ notifyAll }) => notifyAll()) - .then('only the listener still subscribed should be called', ({ listenerA, listenerB }) => { - expect(listenerA).toHaveBeenCalledTimes(1); - expect(listenerB).toHaveBeenCalledTimes(2); - }); - }, - JestGlobals -); - -scenario( - 'createKeyedSubscription with an unsubscribe called twice', - bdd => { - bdd - .given('a listener of "a" unsubscribed, then a new listener of "a"', () => { - const { notify, subscribe } = createKeyedSubscription(); - const prevListener = JestGlobals.jest.fn(); - const unsubscribePrev = subscribe('a', prevListener); - - unsubscribePrev(); - - const nextListener = JestGlobals.jest.fn(); - - subscribe('a', nextListener); - - return { nextListener, notify, prevListener, unsubscribePrev }; - }) - .when('the first unsubscribe is called again and "a" is notified', ({ notify, unsubscribePrev }) => { - unsubscribePrev(); - notify(['a']); - }) - .then('the new listener should still be called', ({ nextListener, prevListener }) => { - expect(nextListener).toHaveBeenCalledTimes(1); - expect(prevListener).not.toHaveBeenCalled(); - }); - }, - JestGlobals -); diff --git a/packages/component/src/hooks/internal/keyedSubscription/createKeyedSubscription.ts b/packages/component/src/hooks/internal/keyedSubscription/createKeyedSubscription.ts deleted file mode 100644 index 4984ba4c0a..0000000000 --- a/packages/component/src/hooks/internal/keyedSubscription/createKeyedSubscription.ts +++ /dev/null @@ -1,43 +0,0 @@ -type KeyedSubscription = { - /** Calls the listeners of each key. Call it after commit (e.g. from an effect), as listeners may re-render. */ - readonly notify: (keys: Iterable) => void; - /** Calls the listeners of every subscribed key, for a change that affects all keys. Call it after commit, as `notify()`. */ - readonly notifyAll: () => void; - /** Calls the listener when the key is notified, until unsubscribed. */ - readonly subscribe: (key: K, listener: () => void) => () => void; -}; - -/** Creates a store of listeners by key, so a change notifies only the listeners interested in it. */ -export default function createKeyedSubscription(): KeyedSubscription { - const listenersByKey = new Map void>>(); - - const notify = (keys: Iterable) => { - for (const key of new Set(keys)) { - // Copies the listeners: a listener may unsubscribe while being notified. - for (const listener of Array.from(listenersByKey.get(key) ?? [])) { - listener(); - } - } - }; - - return Object.freeze({ - notify, - // Copies the keys: a listener may subscribe or unsubscribe while being notified. - notifyAll: () => notify(Array.from(listenersByKey.keys())), - subscribe: (key: K, listener: () => void) => { - const listeners = listenersByKey.get(key) ?? new Set(); - - listeners.add(listener); - listenersByKey.set(key, listeners); - - return () => { - listeners.delete(listener); - - // Deletes only this set: after it emptied, a later subscribe may have put a new set under the same key. - !listeners.size && listenersByKey.get(key) === listeners && listenersByKey.delete(key); - }; - } - }); -} - -export { type KeyedSubscription }; diff --git a/packages/component/src/hooks/internal/keyedSubscription/useKeyedSubscriptionValue.spec.tsx b/packages/component/src/hooks/internal/keyedSubscription/useKeyedSubscriptionValue.spec.tsx deleted file mode 100644 index 8de7d058b3..0000000000 --- a/packages/component/src/hooks/internal/keyedSubscription/useKeyedSubscriptionValue.spec.tsx +++ /dev/null @@ -1,237 +0,0 @@ -/** @jest-environment @happy-dom/jest-environment */ - -import * as JestGlobals from '@jest/globals'; -import { scenario } from '@testduet/given-when-then'; -import React, { Fragment, memo, useLayoutEffect } from 'react'; -import { render, unmountComponentAtNode } from 'react-dom'; -import { act } from 'react-dom/test-utils'; - -import createKeyedSubscription, { type KeyedSubscription } from './createKeyedSubscription'; -import useKeyedSubscriptionValue from './useKeyedSubscriptionValue'; - -type Store = { - readonly getValue: (key: string) => string | undefined; - readonly renders: { readonly a: (string | undefined)[]; readonly b: (string | undefined)[] }; - readonly subscription: KeyedSubscription; - readonly values: Map; -}; - -function createStore(): Store { - const values = new Map(); - - return { - getValue: key => values.get(key), - renders: { a: [], b: [] }, - subscription: createKeyedSubscription(), - values - }; -} - -function Reader({ store, valueKey }: { readonly store: Store; readonly valueKey: 'a' | 'b' }) { - // eslint-disable-next-line security/detect-object-injection - store.renders[valueKey].push(useKeyedSubscriptionValue(store.subscription.subscribe, valueKey, store.getValue)[0]); - - return null; -} - -// Memoized like activities in the transcript, so only its subscription can re-render it. -const MemoizedReader = memo(Reader); - -// Placed before ``: its layout effect runs after `` rendered and before `` subscribes. -// Changes "a" without notifying, so only the re-check after subscribing can see it. -function SetValueBeforeSubscribe({ store }: { readonly store: Store }) { - useLayoutEffect(() => { - store.values.set('a', 'missed'); - }, [store]); - - return null; -} - -// Placed before ``: its layout effect runs after the old subscription is cleaned up and before `` subscribes. -// Changes "b" to the value of "a" and notifies "b", which nobody subscribed to yet. -function SetBToAOnMount({ store }: { readonly store: Store }) { - useLayoutEffect(() => { - store.values.set('b', store.values.get('a')); - store.subscription.notify(['b']); - }, [store]); - - return null; -} - -scenario( - 'useKeyedSubscriptionValue', - bdd => { - bdd - .given('readers of "a" and "b"', () => { - const container = document.createElement('div'); - const store = createStore(); - - act(() => { - render( - - - - , - container - ); - }); - - return { container, store }; - }) - .when('"a" changes and is notified', ({ store }) => { - store.values.set('a', '1'); - - act(() => store.subscription.notify(['a'])); - }) - .then('only the reader of "a" should re-render', ({ store }) => { - expect(store.renders.a).toStrictEqual([undefined, '1']); - expect(store.renders.b).toStrictEqual([undefined]); - }) - .when('"b" is notified without a change', ({ store }) => { - act(() => store.subscription.notify(['b'])); - }) - .then('the reader of "b" should not re-render', ({ store }) => expect(store.renders.b).toStrictEqual([undefined])) - .when('the readers unmount and "a" changes and is notified', ({ container, store }) => { - act(() => { - unmountComponentAtNode(container); - }); - - store.values.set('a', '2'); - - act(() => store.subscription.notify(['a'])); - }) - .then('the reader of "a" should not render again, it unsubscribed', ({ store }) => - expect(store.renders.a).toStrictEqual([undefined, '1']) - ); - - bdd - .given('a store', () => ({ container: document.createElement('div'), store: createStore() })) - .when('"a" changes after the reader rendered, before it subscribed', ({ container, store }) => { - act(() => { - render( - - - - , - container - ); - }); - }) - .then('the reader should re-render with the missed value', ({ store }) => - expect(store.renders.a).toStrictEqual([undefined, 'missed']) - ); - - bdd - .given('a reader of "a", "a" is "1" and "b" is "2"', () => { - const container = document.createElement('div'); - const store = createStore(); - - store.values.set('a', '1'); - store.values.set('b', '2'); - - act(() => { - render(, container); - }); - - return { container, store }; - }) - .when('the reader switches to "b"', ({ container, store }) => { - act(() => { - render(, container); - }); - }) - .then('the reader should render "2" from its first render with "b"', ({ store }) => { - expect(store.renders.a).toStrictEqual(['1']); - // Re-renders once: its state holds "a" until the re-check after subscribing to "b". - expect(store.renders.b).toStrictEqual(['2', '2']); - }) - .when('"a" changes and is notified', ({ store }) => { - store.values.set('a', '3'); - - act(() => store.subscription.notify(['a'])); - }) - .then('the reader should not re-render, it unsubscribed from "a"', ({ store }) => - expect(store.renders.b).toStrictEqual(['2', '2']) - ) - .when('"b" changes and is notified', ({ store }) => { - store.values.set('b', '4'); - - act(() => store.subscription.notify(['b'])); - }) - .then('the reader should re-render with the new value of "b"', ({ store }) => - expect(store.renders.b).toStrictEqual(['2', '2', '4']) - ); - - bdd - .given('a reader of "a", "a" is "1" and "b" is "2"', () => { - const container = document.createElement('div'); - const store = createStore(); - - store.values.set('a', '1'); - store.values.set('b', '2'); - - // `{false}` keeps the reader at the same position as in the next render, so it updates instead of remounting. - act(() => { - render( - - {false} - - , - container - ); - }); - - return { container, store }; - }) - .when('the reader switches to "b" as "b" changes to "1" before it subscribes', ({ container, store }) => { - act(() => { - render( - - - - , - container - ); - }); - }) - .then('the reader should re-render with "1", the new value of "b"', ({ store }) => { - expect(store.values.get('b')).toBe('1'); - // Rendered "2" first, then "1" after the re-check, although "1" is also the last value of "a". - expect(store.renders.b).toStrictEqual(['2', '1']); - }); - - bdd - .given('a reader of a value which is a function', () => { - const container = document.createElement('div'); - const renders: (() => void)[] = []; - const subscription = createKeyedSubscription(); - const values = { current: JestGlobals.jest.fn() }; - const getValue = () => values.current; - - function FunctionReader() { - renders.push(useKeyedSubscriptionValue(subscription.subscribe, 'a', getValue)[0]); - - return null; - } - - act(() => { - render(, container); - }); - - return { initialValue: values.current, renders, subscription, values }; - }) - .when('the value changes to another function and is notified', ({ subscription, values }) => { - values.current = JestGlobals.jest.fn(); - - act(() => subscription.notify(['a'])); - }) - .then('the reader should re-render with the new function', ({ initialValue, renders, values }) => - expect(renders).toStrictEqual([initialValue, values.current]) - ) - .and('neither function should be called, they are values, not state updaters', ({ initialValue, values }) => { - expect(initialValue).not.toHaveBeenCalled(); - expect(values.current).not.toHaveBeenCalled(); - }); - }, - JestGlobals -); diff --git a/packages/component/src/hooks/internal/keyedSubscription/useKeyedSubscriptionValue.ts b/packages/component/src/hooks/internal/keyedSubscription/useKeyedSubscriptionValue.ts deleted file mode 100644 index eb8f0b79e4..0000000000 --- a/packages/component/src/hooks/internal/keyedSubscription/useKeyedSubscriptionValue.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { useLayoutEffect, useMemo, useState } from 'react'; - -import { type KeyedSubscription } from './createKeyedSubscription'; - -/** - * Returns `[getValue(key)]` and re-renders when its result changes (compared by identity) after `key` is notified. - * - * `subscribe` and `getValue` should be stable: a new function re-subscribes. - */ -export default function useKeyedSubscriptionValue( - subscribe: KeyedSubscription['subscribe'], - key: K, - getValue: (key: K) => T -): readonly [T] { - const value = getValue(key); - - // The state only triggers re-renders, React skips them when the same tuple is returned. - // It holds the key with the value: after a key switch, the state still holds the previous key, so the re-check always re-renders, - // even if the new key's value has just changed to the previous key's value. - const [, setState] = useState(() => Object.freeze([key, value] as const)); - - useLayoutEffect(() => { - const handleChange = () => - setState(state => { - const nextValue = getValue(key); - - return state[0] === key && state[1] === nextValue ? state : Object.freeze([key, nextValue] as const); - }); - - // Catches changes between render and subscription. - handleChange(); - - return subscribe(key, handleChange); - }, [getValue, key, subscribe]); - - return useMemo(() => Object.freeze([value] as const), [value]); -} diff --git a/packages/component/src/hooks/internal/useKeyedStoreValue.spec.tsx b/packages/component/src/hooks/internal/useKeyedStoreValue.spec.tsx new file mode 100644 index 0000000000..dbf274cd56 --- /dev/null +++ b/packages/component/src/hooks/internal/useKeyedStoreValue.spec.tsx @@ -0,0 +1,163 @@ +/** @jest-environment @happy-dom/jest-environment */ + +import * as JestGlobals from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; +import React, { Fragment, memo, useLayoutEffect } from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { act } from 'react-dom/test-utils'; + +import createKeyedStore, { type KeyedStore } from './createKeyedStore'; +import useKeyedStoreValue from './useKeyedStoreValue'; + +type Renders = { readonly a: (string | undefined)[]; readonly b: (string | undefined)[] }; + +function Reader({ + renders, + store, + valueKey +}: { + readonly renders: Renders; + readonly store: KeyedStore; + readonly valueKey: 'a' | 'b'; +}) { + // eslint-disable-next-line security/detect-object-injection + renders[valueKey].push(useKeyedStoreValue(store, valueKey)[0]); + + return null; +} + +// Memoized like activities in the transcript, so only its subscription can re-render it. +const MemoizedReader = memo(Reader); + +// Placed before ``: its layout effect runs after `` rendered and before `` subscribes. +function SetOnMount({ + store, + value, + valueKey +}: { + readonly store: KeyedStore; + readonly value: string; + readonly valueKey: string; +}) { + useLayoutEffect(() => store.set(valueKey, value), [store, value, valueKey]); + + return null; +} + +scenario( + 'useKeyedStoreValue', + bdd => { + bdd + .given('readers of "a" and "b"', () => { + const container = document.createElement('div'); + const renders: Renders = { a: [], b: [] }; + const store = createKeyedStore(); + + act(() => { + render( + + + + , + container + ); + }); + + return { container, renders, store }; + }) + .when('"a" is set', ({ store }) => { + act(() => store.set('a', '1')); + }) + .then('only the reader of "a" should re-render', ({ renders }) => { + expect(renders.a).toStrictEqual([undefined, '1']); + expect(renders.b).toStrictEqual([undefined]); + }) + .when('"a" is set to the same value', ({ store }) => { + act(() => store.set('a', '1')); + }) + .then('the reader of "a" should not re-render', ({ renders }) => + expect(renders.a).toStrictEqual([undefined, '1']) + ) + .when('"a" is deleted', ({ store }) => { + act(() => store.delete('a')); + }) + .then('the reader of "a" should re-render without a value', ({ renders }) => + expect(renders.a).toStrictEqual([undefined, '1', undefined]) + ) + .when('the readers unmount and "a" is set', ({ container, store }) => { + act(() => { + unmountComponentAtNode(container); + }); + + act(() => store.set('a', '2')); + }) + .then('the reader of "a" should not render again, it unsubscribed', ({ renders }) => + expect(renders.a).toStrictEqual([undefined, '1', undefined]) + ); + + bdd + .given('a store', () => ({ renders: { a: [], b: [] } as Renders, store: createKeyedStore() })) + .when('"a" is set after the reader rendered, before it subscribed', ({ renders, store }) => { + act(() => { + render( + + + + , + document.createElement('div') + ); + }); + }) + .then('the reader should re-render with the missed value', ({ renders }) => + expect(renders.a).toStrictEqual([undefined, 'missed']) + ); + + bdd + .given('a reader of "a", "a" is "1" and "b" is "2"', () => { + const container = document.createElement('div'); + const renders: Renders = { a: [], b: [] }; + const store = createKeyedStore(); + + store.set('a', '1'); + store.set('b', '2'); + + // `{false}` keeps the reader at the same position as in the next render, so it updates instead of remounting. + act(() => { + render( + + {false} + + , + container + ); + }); + + return { container, renders, store }; + }) + .when( + 'the reader switches to "b" as "b" changes to "1" before it subscribes', + ({ container, renders, store }) => { + act(() => { + render( + + + + , + container + ); + }); + } + ) + .then('the reader should re-render with "1", the new value of "b"', ({ renders }) => + // Rendered "2" first, then "1" after the re-check, although "1" is also the value it rendered for "a". + expect(renders.b).toStrictEqual(['2', '1']) + ) + .when('"a" is set', ({ store }) => { + act(() => store.set('a', '3')); + }) + .then('the reader should not re-render, it unsubscribed from "a"', ({ renders }) => + expect(renders.b).toStrictEqual(['2', '1']) + ); + }, + JestGlobals +); diff --git a/packages/component/src/hooks/internal/useKeyedStoreValue.ts b/packages/component/src/hooks/internal/useKeyedStoreValue.ts new file mode 100644 index 0000000000..4a2d40fd55 --- /dev/null +++ b/packages/component/src/hooks/internal/useKeyedStoreValue.ts @@ -0,0 +1,33 @@ +import { useLayoutEffect, useMemo, useState } from 'react'; + +import { type ReadonlyKeyedStore } from './createKeyedStore'; + +/** + * Returns the value of the key in the store, and re-renders when it changes. + * + * When React 18 is the minimum, replace the body with + * `useSyncExternalStore(useCallback(listener => store.subscribe(key, listener), [key, store]), () => store.get(key))`. + */ +export default function useKeyedStoreValue(store: ReadonlyKeyedStore, key: string): readonly [T | undefined] { + const value = store.get(key); + + // The state only triggers re-renders. It holds the key: after a key switch, the re-check below always re-renders, + // even if the value of the new key has just changed to the value rendered for the previous key. + const [, setState] = useState(() => Object.freeze([key, value])); + + useLayoutEffect(() => { + const handleChange = () => + setState(state => { + const nextValue = store.get(key); + + return state[0] === key && Object.is(state[1], nextValue) ? state : Object.freeze([key, nextValue]); + }); + + // Catches changes between render and subscription. + handleChange(); + + return store.subscribe(key, handleChange); + }, [key, store]); + + return useMemo(() => Object.freeze([value] as const), [value]); +} diff --git a/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.spec.tsx b/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.spec.tsx deleted file mode 100644 index 911b906d8c..0000000000 --- a/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.spec.tsx +++ /dev/null @@ -1,103 +0,0 @@ -/** @jest-environment @happy-dom/jest-environment */ - -import 'core-js/features/set/index.js'; - -import * as JestGlobals from '@jest/globals'; -import { scenario } from '@testduet/given-when-then'; -import React from 'react'; -import { render } from 'react-dom'; -import { act } from 'react-dom/test-utils'; - -import ActivityLogicalGroupingComposer from './ActivityLogicalGroupingComposer'; -import { - useActivityLogicalGroupingContext, - type ActivityLogicalGroupingContextType -} from './private/ActivityLogicalGroupingContext'; - -function ContextReader({ contexts }: { readonly contexts: ActivityLogicalGroupingContextType[] }) { - contexts.push(useActivityLogicalGroupingContext()); - - return null; -} - -scenario( - 'ActivityLogicalGroupingComposer notifies only the keys that changed', - bdd => { - bdd - .given('listeners for activities "a" and "b" and for group "g"', () => { - const contexts: ActivityLogicalGroupingContextType[] = []; - - act(() => { - render( - - - , - document.createElement('div') - ); - }); - - const listeners = { a: JestGlobals.jest.fn(), b: JestGlobals.jest.fn(), g: JestGlobals.jest.fn() }; - - // The context never changes after the first render. - const [context] = contexts; - - context.subscribeLogicalGroupKey('a', listeners.a); - context.subscribeLogicalGroupKey('b', listeners.b); - context.subscribeGroupIsCollapsed('g', listeners.g); - - return { context, listeners }; - }) - .when('group "g" is added with "a"', ({ context }) => { - context.setLogicalGrouping('g', { activityKeys: ['a'], isCollapsed: false }); - }) - .then('"a" and "g" should be notified, not "b"', ({ listeners }) => { - expect(listeners.a).toHaveBeenCalledTimes(1); - expect(listeners.b).not.toHaveBeenCalled(); - expect(listeners.g).toHaveBeenCalledTimes(1); - }) - .when('group "g" is updated with "b" added, still expanded', ({ context, listeners }) => { - Object.values(listeners).forEach(listener => listener.mockClear()); - - context.setLogicalGrouping('g', { activityKeys: ['a', 'b'], isCollapsed: false }); - }) - .then('only "b" should be notified', ({ listeners }) => { - expect(listeners.a).not.toHaveBeenCalled(); - expect(listeners.b).toHaveBeenCalledTimes(1); - expect(listeners.g).not.toHaveBeenCalled(); - }) - .when('group "g" collapses', ({ context, listeners }) => { - Object.values(listeners).forEach(listener => listener.mockClear()); - - context.setLogicalGrouping('g', { activityKeys: ['a', 'b'], isCollapsed: true }); - }) - .then('only "g" should be notified', ({ listeners }) => { - expect(listeners.a).not.toHaveBeenCalled(); - expect(listeners.b).not.toHaveBeenCalled(); - expect(listeners.g).toHaveBeenCalledTimes(1); - }) - .when('"a" moves from group "g" to group "h"', ({ context, listeners }) => { - Object.values(listeners).forEach(listener => listener.mockClear()); - - context.setLogicalGrouping('h', { activityKeys: ['a'], isCollapsed: false }); - }) - .then('only "a" should be notified, and its group key should read "h"', ({ context, listeners }) => { - expect(listeners.a).toHaveBeenCalledTimes(1); - expect(listeners.b).not.toHaveBeenCalled(); - expect(listeners.g).not.toHaveBeenCalled(); - expect(context.getLogicalGroupKey('a')).toBe('h'); - }) - .when('group "g" is removed', ({ context, listeners }) => { - Object.values(listeners).forEach(listener => listener.mockClear()); - - context.removeLogicalGrouping('g'); - }) - .then('"b" and "g" should be notified, "a" should stay in "h"', ({ context, listeners }) => { - expect(listeners.a).not.toHaveBeenCalled(); // "a" moved to "h" before "g" was removed. - expect(listeners.b).toHaveBeenCalledTimes(1); - expect(listeners.g).toHaveBeenCalledTimes(1); - expect(context.getLogicalGroupKey('a')).toBe('h'); - expect(context.getLogicalGroupKey('b')).toBeUndefined(); - }); - }, - JestGlobals -); diff --git a/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts b/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts index 0297a79052..ddc498613c 100644 --- a/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts +++ b/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts @@ -1,8 +1,8 @@ import { reactNode, validateProps } from '@msinternal/botframework-webchat-react-valibot'; -import { createElement, memo, useCallback, useMemo, useRef, useState } from 'react'; +import { createElement, memo, useCallback, useMemo, useState } from 'react'; import { object, optional, pipe, readonly, type InferInput } from 'valibot'; -import createKeyedSubscription from '../../hooks/internal/keyedSubscription/createKeyedSubscription'; +import createKeyedStore from '../../hooks/internal/createKeyedStore'; import ActivityLogicalGroupingContext, { type ActivityLogicalGroupingContextType, type LogicalGrouping @@ -20,106 +20,43 @@ type ActivityLogicalGroupingComposerProps = InferInput { const { children } = validateProps(activityLogicalGroupingComposerPropsSchema, props); - const logicalGroupingsRef = useRef>(new Map()); - const activityToGroupMapRef = useRef>(new Map()); - const [groupIsCollapsedSubscription] = useState(() => createKeyedSubscription()); - const [logicalGroupKeySubscription] = useState(() => createKeyedSubscription()); + // Both stores notify only the keys whose value changed. + const [logicalGroupingStore] = useState(() => createKeyedStore()); + const [logicalGroupKeyStore] = useState(() => createKeyedStore()); - // Notifies only the activities whose group key changed. - const setActivityToGroupMap = useCallback( - (nextActivityToGroupMap: ReadonlyMap) => { - const { current: prevActivityToGroupMap } = activityToGroupMapRef; - - activityToGroupMapRef.current = nextActivityToGroupMap; - - logicalGroupKeySubscription.notify( - Array.from(new Set([...prevActivityToGroupMap.keys(), ...nextActivityToGroupMap.keys()])).filter( - activityKey => prevActivityToGroupMap.get(activityKey) !== nextActivityToGroupMap.get(activityKey) - ) - ); - }, - [activityToGroupMapRef, logicalGroupKeySubscription] + // Removes the activity from the group, unless it has already moved to another group. + const leaveLogicalGrouping = useCallback( + (activityKey: string, key: string) => + logicalGroupKeyStore.get(activityKey) === key && logicalGroupKeyStore.delete(activityKey), + [logicalGroupKeyStore] ); - // Setting and removing notify `useGroupIsCollapsed()` and `useLogicalGroupKey()`, which re-render: call them from effects, never while rendering. + // Setting and removing re-render the activities that subscribed: call them from effects, never while rendering. const setLogicalGrouping = useCallback( (key: string, grouping: LogicalGrouping) => { - const prevGrouping = logicalGroupingsRef.current.get(key); - const prevGroupingKeys = prevGrouping?.activityKeys ?? []; - - logicalGroupingsRef.current.set(key, grouping); - - const toRemoveSet = new Set(prevGroupingKeys).difference(new Set(grouping.activityKeys)); - const toAddSet = new Set(grouping.activityKeys).difference(new Set(prevGroupingKeys)); - - setActivityToGroupMap( - new Map( - [].concat( - Array.from(activityToGroupMapRef.current).filter( - ([activityKey, groupKey]) => !!groupKey && (groupKey !== key || !toRemoveSet.has(activityKey)) - ), - Array.from(toAddSet).map(activityKey => [activityKey, key] as const) - ) - ) - ); - - prevGrouping?.isCollapsed !== grouping.isCollapsed && groupIsCollapsedSubscription.notify([key]); + logicalGroupingStore + .get(key) + ?.activityKeys.forEach( + activityKey => !grouping.activityKeys.includes(activityKey) && leaveLogicalGrouping(activityKey, key) + ); + + logicalGroupingStore.set(key, grouping); + grouping.activityKeys.forEach(activityKey => logicalGroupKeyStore.set(activityKey, key)); }, - [activityToGroupMapRef, groupIsCollapsedSubscription, logicalGroupingsRef, setActivityToGroupMap] + [leaveLogicalGrouping, logicalGroupKeyStore, logicalGroupingStore] ); const removeLogicalGrouping = useCallback( (key: string) => { - logicalGroupingsRef.current.delete(key); - - setActivityToGroupMap(new Map([...activityToGroupMapRef.current].filter(([, value]) => value !== key))); - groupIsCollapsedSubscription.notify([key]); - }, - [activityToGroupMapRef, groupIsCollapsedSubscription, logicalGroupingsRef, setActivityToGroupMap] - ); - - const getLogicalGroupKey = useCallback( - (activityKey: string): string | undefined => activityToGroupMapRef.current.get(activityKey), - [activityToGroupMapRef] - ); - - const getGroupIsCollapsed = useCallback( - (groupKey: string): boolean | undefined => logicalGroupingsRef.current.get(groupKey)?.isCollapsed, - [logicalGroupingsRef] - ); - - const getGroupBoundaries = useCallback( - (groupKey: string): [string, string] => { - const group = logicalGroupingsRef.current.get(groupKey); - if (!group || !group.activityKeys.length) { - return [undefined, undefined]; - } - // eslint-disable-next-line no-magic-numbers - return [group.activityKeys.at(0), group.activityKeys.at(-1)]; + logicalGroupingStore.get(key)?.activityKeys.forEach(activityKey => leaveLogicalGrouping(activityKey, key)); + logicalGroupingStore.delete(key); }, - [logicalGroupingsRef] + [leaveLogicalGrouping, logicalGroupingStore] ); - const contextValue: ActivityLogicalGroupingContextType = useMemo( - () => - Object.freeze({ - getGroupBoundaries, - getGroupIsCollapsed, - getLogicalGroupKey, - removeLogicalGrouping, - setLogicalGrouping, - subscribeGroupIsCollapsed: groupIsCollapsedSubscription.subscribe, - subscribeLogicalGroupKey: logicalGroupKeySubscription.subscribe - }), - [ - getGroupBoundaries, - getGroupIsCollapsed, - getLogicalGroupKey, - groupIsCollapsedSubscription, - logicalGroupKeySubscription, - removeLogicalGrouping, - setLogicalGrouping - ] + const contextValue = useMemo( + () => Object.freeze({ logicalGroupKeyStore, logicalGroupingStore, removeLogicalGrouping, setLogicalGrouping }), + [logicalGroupKeyStore, logicalGroupingStore, removeLogicalGrouping, setLogicalGrouping] ); return createElement(ActivityLogicalGroupingContext.Provider, { value: contextValue }, children); diff --git a/packages/component/src/providers/ActivityLogicalGrouping/index.ts b/packages/component/src/providers/ActivityLogicalGrouping/index.ts index 217ae4b092..bb85553bf6 100644 --- a/packages/component/src/providers/ActivityLogicalGrouping/index.ts +++ b/packages/component/src/providers/ActivityLogicalGrouping/index.ts @@ -1,5 +1,4 @@ export { default as ActivityLogicalGroupingComposer } from './ActivityLogicalGroupingComposer'; -export { default as useGroupIsCollapsed } from './useGroupIsCollapsed'; export { default as useLogicalGroupKey } from './useLogicalGroupKey'; export { default as useRemoveLogicalGrouping } from './useRemoveLogicalGrouping'; export { default as useSetLogicalGrouping } from './useSetLogicalGrouping'; diff --git a/packages/component/src/providers/ActivityLogicalGrouping/private/ActivityLogicalGroupingContext.ts b/packages/component/src/providers/ActivityLogicalGrouping/private/ActivityLogicalGroupingContext.ts index 88005b1c52..cb8b253384 100644 --- a/packages/component/src/providers/ActivityLogicalGrouping/private/ActivityLogicalGroupingContext.ts +++ b/packages/component/src/providers/ActivityLogicalGrouping/private/ActivityLogicalGroupingContext.ts @@ -1,3 +1,4 @@ +import { type ReadonlyKeyedStore } from '../../../hooks/internal/createKeyedStore'; import createContextAndHook from '../../createContextAndHook'; type LogicalGrouping = { @@ -6,14 +7,13 @@ type LogicalGrouping = { }; type ActivityLogicalGroupingContextType = { - readonly getGroupBoundaries: (groupKey: string) => [string | undefined, string | undefined]; - readonly getGroupIsCollapsed: (groupKey: string) => boolean | undefined; - readonly getLogicalGroupKey: (activityKey: string) => string | undefined; + /** Group key by activity key. Read with `useLogicalGroupKey()` while rendering, or `get()` at event time. */ + readonly logicalGroupKeyStore: ReadonlyKeyedStore; + /** Logical grouping by group key. */ + readonly logicalGroupingStore: ReadonlyKeyedStore; readonly removeLogicalGrouping: (key: string) => void; /** Adds the logical grouping, or replaces the one with the same key. */ readonly setLogicalGrouping: (key: string, grouping: LogicalGrouping) => void; - readonly subscribeGroupIsCollapsed: (groupKey: string, listener: () => void) => () => void; - readonly subscribeLogicalGroupKey: (activityKey: string, listener: () => void) => () => void; }; const { contextComponentType, useContext } = createContextAndHook( diff --git a/packages/component/src/providers/ActivityLogicalGrouping/private/useGetGroupIsCollapsed.ts b/packages/component/src/providers/ActivityLogicalGrouping/private/useGetGroupIsCollapsed.ts deleted file mode 100644 index ff071aaf46..0000000000 --- a/packages/component/src/providers/ActivityLogicalGrouping/private/useGetGroupIsCollapsed.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useActivityLogicalGroupingContext } from './ActivityLogicalGroupingContext'; - -/** - * Returns a getter that reads whether the group is collapsed at call time, without subscribing. - * - * Only for keyboard navigation in `TranscriptFocusComposer`, which looks up arbitrary groups while handling a key press. - * Not exported from the provider: components that render from it must use `useGroupIsCollapsed()`. - */ -export default function useGetGroupIsCollapsed(): (groupKey: string) => boolean | undefined { - return useActivityLogicalGroupingContext().getGroupIsCollapsed; -} diff --git a/packages/component/src/providers/ActivityLogicalGrouping/private/useGetLogicalGroupBoundaries.ts b/packages/component/src/providers/ActivityLogicalGrouping/private/useGetLogicalGroupBoundaries.ts deleted file mode 100644 index 2089f7eba5..0000000000 --- a/packages/component/src/providers/ActivityLogicalGrouping/private/useGetLogicalGroupBoundaries.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { useActivityLogicalGroupingContext } from './ActivityLogicalGroupingContext'; - -/** - * Returns a getter that reads the latest first and last activity keys of a group at call time, without subscribing. - * - * Only for keyboard navigation in `TranscriptFocusComposer`, which looks up arbitrary groups while handling a key press. - */ -export default function useGetLogicalGroupBoundaries(): (groupKey: string) => [string | undefined, string | undefined] { - return useActivityLogicalGroupingContext().getGroupBoundaries; -} diff --git a/packages/component/src/providers/ActivityLogicalGrouping/private/useGetLogicalGroupKey.ts b/packages/component/src/providers/ActivityLogicalGrouping/private/useGetLogicalGroupKey.ts deleted file mode 100644 index 8fce27991f..0000000000 --- a/packages/component/src/providers/ActivityLogicalGrouping/private/useGetLogicalGroupKey.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useActivityLogicalGroupingContext } from './ActivityLogicalGroupingContext'; - -/** - * Returns a getter that reads the latest group key at call time, without subscribing. - * - * Only for keyboard navigation in `TranscriptFocusComposer`, which looks up arbitrary activities while handling a key press. - * Not exported from the provider: components that render from the group key must use `useLogicalGroupKey()`. - */ -export default function useGetLogicalGroupKey(): (activityKey: string) => string | undefined { - return useActivityLogicalGroupingContext().getLogicalGroupKey; -} diff --git a/packages/component/src/providers/ActivityLogicalGrouping/useGroupIsCollapsed.spec.tsx b/packages/component/src/providers/ActivityLogicalGrouping/useGroupIsCollapsed.spec.tsx deleted file mode 100644 index 8f88504fb0..0000000000 --- a/packages/component/src/providers/ActivityLogicalGrouping/useGroupIsCollapsed.spec.tsx +++ /dev/null @@ -1,95 +0,0 @@ -/** @jest-environment @happy-dom/jest-environment */ - -import 'core-js/features/set/index.js'; - -import * as JestGlobals from '@jest/globals'; -import { scenario } from '@testduet/given-when-then'; -import React, { memo } from 'react'; -import { render } from 'react-dom'; -import { act } from 'react-dom/test-utils'; - -import usePartGroupingLogicalGroup from '../../Middleware/ActivityGrouping/ui/PartGrouping/private/usePartGroupingLogicalGroup'; -import ActivityLogicalGroupingComposer from './ActivityLogicalGroupingComposer'; -import useGroupIsCollapsed from './useGroupIsCollapsed'; - -type Renders = (boolean | undefined)[]; - -function Reader({ groupKey, renders }: { readonly groupKey: string; readonly renders: Renders }) { - renders.push(useGroupIsCollapsed(groupKey)[0]); - - return null; -} - -// Memoized like activities in the transcript, so only its subscription can re-render it. -const MemoizedReader = memo(Reader); - -function PartGroup({ - activityKeys, - isCollapsed, - renders -}: { - readonly activityKeys: readonly string[]; - readonly isCollapsed: boolean; - readonly renders: Renders; -}) { - const groupKey = usePartGroupingLogicalGroup({ activityKeys, isCollapsed }); - - return ; -} - -const ACTIVITY_KEYS_1 = Object.freeze(['a']); -const ACTIVITY_KEYS_2 = Object.freeze(['b']); - -function renderGroups( - container: HTMLElement, - { isGroup1Collapsed, renders1, renders2 }: { isGroup1Collapsed: boolean; renders1: Renders; renders2: Renders } -) { - act(() => { - render( - - - - , - container - ); - }); -} - -scenario( - 'useGroupIsCollapsed', - bdd => { - bdd - .given('an empty transcript', (): { container: HTMLElement; renders1: Renders; renders2: Renders } => ({ - container: document.createElement('div'), - renders1: [], - renders2: [] - })) - .when('two expanded part groups are added', ({ container, renders1, renders2 }) => - renderGroups(container, { isGroup1Collapsed: false, renders1, renders2 }) - ) - .then('both readers should see an expanded group', ({ renders1, renders2 }) => { - // Rendered before the group is added in a layout effect, then re-rendered with the group before paint. - expect(renders1).toStrictEqual([undefined, false]); - expect(renders2).toStrictEqual([undefined, false]); - }) - .when('group 1 collapses', ({ container, renders1, renders2 }) => { - renders1.splice(0); - renders2.splice(0); - - renderGroups(container, { isGroup1Collapsed: true, renders1, renders2 }); - }) - .then('group 1 reader should re-render once, collapsed', ({ renders1 }) => expect(renders1).toStrictEqual([true])) - .and('group 2 reader should not re-render, its group did not change', ({ renders2 }) => - expect(renders2).toStrictEqual([]) - ) - .when('group 1 expands', ({ container, renders1, renders2 }) => { - renders1.splice(0); - renders2.splice(0); - - renderGroups(container, { isGroup1Collapsed: false, renders1, renders2 }); - }) - .then('group 1 reader should re-render once, expanded', ({ renders1 }) => expect(renders1).toStrictEqual([false])) - .and('group 2 reader should not re-render', ({ renders2 }) => expect(renders2).toStrictEqual([])); - }, - JestGlobals -); diff --git a/packages/component/src/providers/ActivityLogicalGrouping/useGroupIsCollapsed.ts b/packages/component/src/providers/ActivityLogicalGrouping/useGroupIsCollapsed.ts deleted file mode 100644 index eae7e0241d..0000000000 --- a/packages/component/src/providers/ActivityLogicalGrouping/useGroupIsCollapsed.ts +++ /dev/null @@ -1,13 +0,0 @@ -import useKeyedSubscriptionValue from '../../hooks/internal/keyedSubscription/useKeyedSubscriptionValue'; -import { useActivityLogicalGroupingContext } from './private/ActivityLogicalGroupingContext'; - -/** - * Returns whether the logical group is collapsed, or `undefined` if the group does not exist. - * - * The component re-renders only when this group collapses, expands, or is removed. - */ -export default function useGroupIsCollapsed(groupKey: string): readonly [boolean | undefined] { - const { getGroupIsCollapsed, subscribeGroupIsCollapsed } = useActivityLogicalGroupingContext(); - - return useKeyedSubscriptionValue(subscribeGroupIsCollapsed, groupKey, getGroupIsCollapsed); -} diff --git a/packages/component/src/providers/ActivityLogicalGrouping/useLogicalGroupKey.spec.tsx b/packages/component/src/providers/ActivityLogicalGrouping/useLogicalGroupKey.spec.tsx index cf9925e0a0..e925cfe22e 100644 --- a/packages/component/src/providers/ActivityLogicalGrouping/useLogicalGroupKey.spec.tsx +++ b/packages/component/src/providers/ActivityLogicalGrouping/useLogicalGroupKey.spec.tsx @@ -1,7 +1,5 @@ /** @jest-environment @happy-dom/jest-environment */ -import 'core-js/features/set/index.js'; - import * as JestGlobals from '@jest/globals'; import { scenario } from '@testduet/given-when-then'; import React, { memo, useLayoutEffect } from 'react'; @@ -10,16 +8,21 @@ import { act } from 'react-dom/test-utils'; import ActivityLogicalGroupingComposer from './ActivityLogicalGroupingComposer'; import useLogicalGroupKey from './useLogicalGroupKey'; +import useRemoveLogicalGrouping from './useRemoveLogicalGrouping'; import useSetLogicalGrouping from './useSetLogicalGrouping'; type Renders = { readonly a: (string | undefined)[]; readonly b: (string | undefined)[] }; -function Group({ activityKeys }: { readonly activityKeys: string[] }) { +// Registers like part grouping: set in a layout effect, removed on unmount. +function Group({ activityKeys, groupKey }: { readonly activityKeys: readonly string[]; readonly groupKey: string }) { + const removeLogicalGrouping = useRemoveLogicalGrouping(); const setLogicalGrouping = useSetLogicalGrouping(); useLayoutEffect(() => { - setLogicalGrouping('g', { activityKeys, isCollapsed: false }); - }, [activityKeys, setLogicalGrouping]); + setLogicalGrouping(groupKey, { activityKeys, isCollapsed: false }); + }, [activityKeys, groupKey, setLogicalGrouping]); + + useLayoutEffect(() => () => removeLogicalGrouping(groupKey), [groupKey, removeLogicalGrouping]); return null; } @@ -34,13 +37,19 @@ function Row({ activityKey, renders }: { readonly activityKey: 'a' | 'b'; readon // Memoized like activities in the transcript, so only its subscription can re-render it. const MemoizedRow = memo(Row); -function renderGroup(container: HTMLElement, activityKeys: string[], renders: Renders) { +// Groups render in the order given, each group key mapped to its activity keys. +function renderGroups(container: HTMLElement, groups: Record, renders: Renders) { + renders.a.splice(0); + renders.b.splice(0); + act(() => { render( - + {Object.entries(groups).map(([groupKey, activityKeys]) => ( + + ))} , container ); @@ -53,24 +62,32 @@ scenario( bdd .given('an empty transcript', () => ({ container: document.createElement('div'), renders: { a: [], b: [] } })) .when('activities "a" and "b" are added to group "g"', ({ container, renders }) => - renderGroup(container, ['a', 'b'], renders) + renderGroups(container, { g: ['a', 'b'] }, renders) ) .then('both activities should be in group "g"', ({ renders }) => { - // Rendered before the group is added in a layout effect, then re-rendered with the group before paint. + // Rendered before the group is set in a layout effect, then re-rendered with the group before paint. expect(renders.a).toStrictEqual([undefined, 'g']); expect(renders.b).toStrictEqual([undefined, 'g']); }) - .when('activity "b" leaves the group', ({ container, renders }) => { - renders.a.splice(0); - renders.b.splice(0); - - renderGroup(container, ['a'], renders); + .when('activity "b" leaves the group', ({ container, renders }) => renderGroups(container, { g: ['a'] }, renders)) + .then('only activity "b" should re-render, without a group', ({ renders }) => { + expect(renders.a).toStrictEqual([]); + expect(renders.b).toStrictEqual([undefined]); + }) + .when('activity "a" moves to group "h", which is set before group "g"', ({ container, renders }) => + renderGroups(container, { h: ['a'], g: [] }, renders) + ) + .then('only activity "a" should re-render once, in group "h"', ({ renders }) => { + expect(renders.a).toStrictEqual(['h']); + expect(renders.b).toStrictEqual([]); }) - .then('activity "a" should not re-render, its group did not change', ({ renders }) => + .when('group "g" is removed', ({ container, renders }) => renderGroups(container, { h: ['a'] }, renders)) + .then('activity "a" should not re-render, it stays in group "h"', ({ renders }) => expect(renders.a).toStrictEqual([]) ) - .and('activity "b" should re-render once, without a group', ({ renders }) => - expect(renders.b).toStrictEqual([undefined]) + .when('group "h" is removed', ({ container, renders }) => renderGroups(container, {}, renders)) + .then('activity "a" should re-render once, without a group', ({ renders }) => + expect(renders.a).toStrictEqual([undefined]) ); }, JestGlobals diff --git a/packages/component/src/providers/ActivityLogicalGrouping/useLogicalGroupKey.ts b/packages/component/src/providers/ActivityLogicalGrouping/useLogicalGroupKey.ts index 5122d59c1a..a6c4d28d76 100644 --- a/packages/component/src/providers/ActivityLogicalGrouping/useLogicalGroupKey.ts +++ b/packages/component/src/providers/ActivityLogicalGrouping/useLogicalGroupKey.ts @@ -1,13 +1,7 @@ -import useKeyedSubscriptionValue from '../../hooks/internal/keyedSubscription/useKeyedSubscriptionValue'; +import useKeyedStoreValue from '../../hooks/internal/useKeyedStoreValue'; import { useActivityLogicalGroupingContext } from './private/ActivityLogicalGroupingContext'; -/** - * Returns the key of the logical group the activity belongs to, or `undefined` if it is not part of any group. - * - * The component re-renders only when the group key of this activity changes. - */ +/** Returns the key of the logical group of the activity, or `undefined`. Re-renders only when it changes. */ export default function useLogicalGroupKey(activityKey: string): readonly [string | undefined] { - const { getLogicalGroupKey, subscribeLogicalGroupKey } = useActivityLogicalGroupingContext(); - - return useKeyedSubscriptionValue(subscribeLogicalGroupKey, activityKey, getLogicalGroupKey); + return useKeyedStoreValue(useActivityLogicalGroupingContext().logicalGroupKeyStore, activityKey); } diff --git a/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.groupIsCollapsed.spec.tsx b/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.groupIsCollapsed.spec.tsx index a9068c8c2a..47cb3b7614 100644 --- a/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.groupIsCollapsed.spec.tsx +++ b/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.groupIsCollapsed.spec.tsx @@ -1,8 +1,6 @@ /** @jest-environment @happy-dom/jest-environment */ /* eslint no-magic-numbers: "off" */ -import 'core-js/features/set/index.js'; - import * as JestGlobals from '@jest/globals'; import { scenario } from '@testduet/given-when-then'; import React, { useContext } from 'react'; diff --git a/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.tsx b/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.tsx index 2a040e17c5..85237df11b 100644 --- a/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.tsx +++ b/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.tsx @@ -1,26 +1,16 @@ /* eslint-disable no-magic-numbers */ import random from 'math-random'; import PropTypes from 'prop-types'; -import React, { - memo, - useCallback, - useLayoutEffect, - useMemo, - useState, - type MutableRefObject, - type ReactNode -} from 'react'; - -import createKeyedSubscription from '../../hooks/internal/keyedSubscription/createKeyedSubscription'; +import React, { memo, useCallback, useMemo, type MutableRefObject, type ReactNode } from 'react'; + import usePrevious from '../../hooks/internal/usePrevious'; import useStateRef from '../../hooks/internal/useStateRef'; import useValueRef from '../../hooks/internal/useValueRef'; import scrollIntoViewWithBlockNearest from '../../Utils/scrollIntoViewWithBlockNearest'; -import useGetGroupIsCollapsed from '../ActivityLogicalGrouping/private/useGetGroupIsCollapsed'; -import useGetLogicalGroupKey from '../ActivityLogicalGrouping/private/useGetLogicalGroupKey'; -import useGetLogicalGroupBoundaries from '../ActivityLogicalGrouping/private/useGetLogicalGroupBoundaries'; +import { useActivityLogicalGroupingContext } from '../ActivityLogicalGrouping/private/ActivityLogicalGroupingContext'; import useRenderingActivityKeys from '../RenderingActivities/useRenderingActivityKeys'; import TranscriptFocusContext, { type TranscriptFocusContextType } from './private/Context'; +import getGroupDescendantId from './private/getGroupDescendantId'; type TranscriptFocusComposerProps = Readonly<{ children?: ReactNode | undefined; @@ -39,11 +29,20 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp >(); const [renderingActivityKeys] = useRenderingActivityKeys(); const [, setRawFocusedActivityKey, rawFocusedActivityKeyRef] = useStateRef(); - // Keyboard navigation looks up arbitrary activities and groups while handling a key press, and nothing here renders from them. - // Thus, read them at call time instead of subscribing with `useLogicalGroupKey()` and `useGroupIsCollapsed()`. - const getGroupBoundaries = useGetLogicalGroupBoundaries(); - const getGroupKeyByActivityKey = useGetLogicalGroupKey(); - const getGroupIsCollapsed = useGetGroupIsCollapsed(); + // Keyboard navigation reads arbitrary activities and groups at event time and renders nothing from them: it does not subscribe. + const { logicalGroupKeyStore, logicalGroupingStore } = useActivityLogicalGroupingContext(); + const getGroupKeyByActivityKey = logicalGroupKeyStore.get; + // The logical grouping carries `isCollapsed`, as the group state did. + const getGroupState = logicalGroupingStore.get; + + const getGroupBoundaries = useCallback( + (groupKey: string): readonly [string | undefined, string | undefined] => { + const activityKeys = getGroupState(groupKey)?.activityKeys ?? []; + + return Object.freeze([activityKeys.at(0), activityKeys.at(-1)] as const); + }, + [getGroupState] + ); // As we need to use IDREF for `aria-activedescendant`, // this prefix will differentiate multiple instances of transcript on the same page. @@ -55,19 +54,10 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp ); const getGroupDescendantIdByGroupKey: (groupKey?: string) => string | undefined = useCallback( - (groupKey?: string) => groupKey && `webchat__transcript-focus-${prefix}__group-${groupKey}`, + (groupKey?: string) => getGroupDescendantId(prefix, groupKey), [prefix] ); - const [groupDescendantIdSubscription] = useState(() => createKeyedSubscription()); - - // Notifies `useGroupDescendantIdByGroupKey()` of every group when the getter's output can change, i.e. when `prefix` changes. - // Today, `prefix` is per instance, so this only runs on mount, when the subscribers already have the current ID. - useLayoutEffect( - () => groupDescendantIdSubscription.notifyAll(), - [getGroupDescendantIdByGroupKey, groupDescendantIdSubscription] - ); - const renderingActivityKeysRef = useValueRef(renderingActivityKeys); // While the transcript or any descendants are not focused, if the transcript is updated, reset the user-selected active descendant. @@ -148,10 +138,11 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp } const groupKey = typeof activityKey === 'string' && getGroupKeyByActivityKey(activityKey); + const groupState = groupKey && getGroupState(groupKey); // shortcut for collapsed group // when collapsed, we do not want to focus on the group or scroll it into view - if (groupKey && getGroupIsCollapsed(groupKey)) { + if (groupState?.isCollapsed) { return; } @@ -173,8 +164,8 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp focusByGroupKey, focusedActivityKeyRef, getDescendantIdByActivityKey, - getGroupIsCollapsed, getGroupKeyByActivityKey, + getGroupState, handleFocus, rawFocusedActivityKeyRef, renderingActivityKeysRef, @@ -209,10 +200,11 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp (direction: 'up' | 'down') => { const { current: currentGroupKey } = rawFocusedActivityGroupKeyRef; const { current: orderedActivityKeys } = renderingActivityKeysRef; + const currentGroupState = getGroupState(currentGroupKey); const [firstActivityKey, lastActivityKey] = getGroupBoundaries(currentGroupKey); if (direction === 'down') { - if (!getGroupIsCollapsed(currentGroupKey)) { + if (!currentGroupState?.isCollapsed) { // From expanded group header, go to first activity in the group if (firstActivityKey) { focusByActivityKey(firstActivityKey, true); @@ -240,9 +232,11 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp if (currentIndex !== -1) { const prevActivityKey = orderedActivityKeys[currentIndex - 1]; const prevGroupKey = getGroupKeyByActivityKey(prevActivityKey); + const prevGroupState = + prevGroupKey && prevGroupKey !== currentGroupKey ? getGroupState(prevGroupKey) : undefined; // Previous activity is in a different collapsed group - if (prevGroupKey && prevGroupKey !== currentGroupKey && getGroupIsCollapsed(prevGroupKey)) { + if (prevGroupState?.isCollapsed) { focusByActivityKey(prevActivityKey, false); focusByGroupKey(prevGroupKey); } else { @@ -255,8 +249,8 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp focusByActivityKey, focusByGroupKey, getGroupBoundaries, - getGroupIsCollapsed, getGroupKeyByActivityKey, + getGroupState, rawFocusedActivityGroupKeyRef, renderingActivityKeysRef ] @@ -276,12 +270,13 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp // Moving to a different group const [firstActivityKey, lastActivityKey] = getGroupBoundaries(nextGroupKey); + const nextGroupState = getGroupState(nextGroupKey); if (direction === 'down' && firstActivityKey === nextActivityKey) { // Going down into a new group - always focus header first focusByActivityKey(currentActivityKey, false); focusByGroupKey(nextGroupKey); - } else if (direction === 'up' && lastActivityKey === nextActivityKey && getGroupIsCollapsed(nextGroupKey)) { + } else if (direction === 'up' && lastActivityKey === nextActivityKey && nextGroupState?.isCollapsed) { // Going up into a collapsed group from below - focus header focusByGroupKey(nextGroupKey); } else { @@ -289,7 +284,7 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp focusByActivityKey(nextActivityKey, true); } }, - [focusByActivityKey, focusByGroupKey, getGroupBoundaries, getGroupIsCollapsed, getGroupKeyByActivityKey] + [focusByActivityKey, focusByGroupKey, getGroupBoundaries, getGroupKeyByActivityKey, getGroupState] ); // Helper function to handle delta > 1 navigation @@ -358,14 +353,14 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp // Handle navigation from a focused activity const groupKey = getGroupKeyByActivityKey(focusedActivityKey); const [firstActivityKey, lastActivityKey] = getGroupBoundaries(groupKey); - const isGroupCollapsed = !!groupKey && getGroupIsCollapsed(groupKey); + const groupState = groupKey ? getGroupState(groupKey) : undefined; // In group special cases if (groupKey) { if (direction === 'up') { // For expanded groups: only focus header when going up from first activity // For collapsed groups: focus header when going up from any activity in the group - if ((firstActivityKey === focusedActivityKey && !isGroupCollapsed) || isGroupCollapsed) { + if ((firstActivityKey === focusedActivityKey && !groupState?.isCollapsed) || groupState?.isCollapsed) { // From first activity in expanded group OR any activity in collapsed group, go to group header focusByActivityKey(focusedActivityKey, false); focusByGroupKey(groupKey); @@ -374,7 +369,7 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp } else if (direction === 'down') { // Special handling for collapsed groups: when moving down from any activity in a collapsed group, // jump to the next visible element outside the group - if (isGroupCollapsed) { + if (groupState?.isCollapsed) { const nextActivityKey = getNextActivityLinear(lastActivityKey, direction); return handleGroupTransition(lastActivityKey, nextActivityKey, direction); } @@ -393,8 +388,8 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp focusByGroupKey, focusedActivityKeyRef, getGroupBoundaries, - getGroupIsCollapsed, getGroupKeyByActivityKey, + getGroupState, getNextActivityLinear, handleDeltaNavigation, handleGroupTransition, @@ -415,8 +410,7 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp focusedGroupKeyState: Object.freeze([rawFocusedActivityGroupKey]) as readonly [string], focusRelativeActivity, getDescendantIdByActivityKey, - getGroupDescendantIdByGroupKey, - subscribeGroupDescendantId: groupDescendantIdSubscription.subscribe + prefix }), [ activeDescendantId, @@ -426,8 +420,7 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp focusedActivityKey, focusRelativeActivity, getDescendantIdByActivityKey, - getGroupDescendantIdByGroupKey, - groupDescendantIdSubscription, + prefix, rawFocusedActivityGroupKey, rawFocusedActivityKey ] diff --git a/packages/component/src/providers/TranscriptFocus/private/Context.ts b/packages/component/src/providers/TranscriptFocus/private/Context.ts index c4a496727c..46859baaa5 100644 --- a/packages/component/src/providers/TranscriptFocus/private/Context.ts +++ b/packages/component/src/providers/TranscriptFocus/private/Context.ts @@ -10,8 +10,8 @@ type TranscriptFocusContextType = { focusedGroupKeyState: readonly [string]; focusRelativeActivity: (delta: number) => void; getDescendantIdByActivityKey: (activityKey?: string) => string | undefined; - getGroupDescendantIdByGroupKey: (groupKey?: string) => string | undefined; - subscribeGroupDescendantId: (groupKey: string | undefined, listener: () => void) => () => void; + /** Differentiates the descendant IDs of multiple transcripts on the same page. */ + prefix: string; }; const TranscriptFocusContext = createContext(undefined); diff --git a/packages/component/src/providers/TranscriptFocus/private/getGroupDescendantId.ts b/packages/component/src/providers/TranscriptFocus/private/getGroupDescendantId.ts new file mode 100644 index 0000000000..8eb587b196 --- /dev/null +++ b/packages/component/src/providers/TranscriptFocus/private/getGroupDescendantId.ts @@ -0,0 +1,3 @@ +export default function getGroupDescendantId(prefix: string, groupKey?: string | undefined): string | undefined { + return groupKey && `webchat__transcript-focus-${prefix}__group-${groupKey}`; +} diff --git a/packages/component/src/providers/TranscriptFocus/useGroupDescendantIdByGroupKey.spec.tsx b/packages/component/src/providers/TranscriptFocus/useGroupDescendantIdByGroupKey.spec.tsx deleted file mode 100644 index 5c171dcb04..0000000000 --- a/packages/component/src/providers/TranscriptFocus/useGroupDescendantIdByGroupKey.spec.tsx +++ /dev/null @@ -1,132 +0,0 @@ -/** @jest-environment @happy-dom/jest-environment */ - -import 'core-js/features/set/index.js'; - -import * as JestGlobals from '@jest/globals'; -import { scenario } from '@testduet/given-when-then'; -import React, { Fragment, memo } from 'react'; -import { render } from 'react-dom'; -import { act } from 'react-dom/test-utils'; - -import createKeyedSubscription from '../../hooks/internal/keyedSubscription/createKeyedSubscription'; -import { ActivityLogicalGroupingComposer } from '../ActivityLogicalGrouping'; -import useRenderingActivityKeys from '../RenderingActivities/useRenderingActivityKeys'; -import TranscriptFocusContext, { type TranscriptFocusContextType } from './private/Context'; -import TranscriptFocusComposer from './TranscriptFocusComposer'; -import useGroupDescendantIdByGroupKey from './useGroupDescendantIdByGroupKey'; - -jest.mock('../RenderingActivities/useRenderingActivityKeys'); - -// Every render pushes its value, by group key. -type Renders = Map; - -function Reader({ groupKey, renders }: { readonly groupKey: string | undefined; readonly renders: Renders }) { - const [id] = useGroupDescendantIdByGroupKey(groupKey); - - renders.set(groupKey, [...(renders.get(groupKey) ?? []), id]); - - return null; -} - -// Memoized like activities in the transcript, so only its subscription or the context can re-render it. -const MemoizedReader = memo(Reader); - -function Readers({ renders }: { readonly renders: Renders }) { - return ( - - - - - - ); -} - -scenario( - 'useGroupDescendantIdByGroupKey under ', - bdd => { - bdd - .given('an empty transcript', () => { - jest.mocked(useRenderingActivityKeys).mockReturnValue(Object.freeze([Object.freeze([])])); - - return { container: document.createElement('div'), renders: new Map() as Renders }; - }) - .when('readers of "g1", "g2", and no group render', ({ container, renders }) => { - act(() => { - render( - - - - - , - container - ); - }); - }) - .then('each reader should render its group ID once', ({ renders }) => { - // The composer notifies every group on mount, which does not re-render: the IDs did not change. - expect(renders.get('g1')).toEqual([expect.stringMatching(/^webchat__transcript-focus-[^_]+__group-g1$/u)]); - expect(renders.get('g2')).toEqual([expect.stringMatching(/^webchat__transcript-focus-[^_]+__group-g2$/u)]); - }) - .and('the reader without a group key should render undefined', ({ renders }) => - expect(renders.get(undefined)).toStrictEqual([undefined]) - ); - }, - JestGlobals -); - -scenario( - 'useGroupDescendantIdByGroupKey with a notified change', - bdd => { - bdd - .given('readers of "g1", "g2", and no group, with a context which notifies changed IDs', () => { - const container = document.createElement('div'); - const ids = new Map([ - ['g1', 'id-1'], - ['g2', 'id-2'] - ]); - const renders: Renders = new Map(); - const subscription = createKeyedSubscription(); - - // Only what the hook reads: the getter and the subscription. - const context = Object.freeze({ - getGroupDescendantIdByGroupKey: (groupKey?: string) => ids.get(groupKey), - subscribeGroupDescendantId: subscription.subscribe - }) satisfies Pick< - TranscriptFocusContextType, - 'getGroupDescendantIdByGroupKey' | 'subscribeGroupDescendantId' - > as TranscriptFocusContextType; - - act(() => { - render( - - - , - container - ); - }); - - return { ids, renders, subscription }; - }) - .when('the ID of "g2" changes and every group is notified', ({ ids, subscription }) => { - ids.set('g2', 'id-2b'); - - act(() => subscription.notifyAll()); - }) - .then('only the reader of "g2" should re-render, with the new ID', ({ renders }) => { - expect(renders.get('g1')).toStrictEqual(['id-1']); - expect(renders.get('g2')).toStrictEqual(['id-2', 'id-2b']); - expect(renders.get(undefined)).toStrictEqual([undefined]); - }) - .when('the ID of "g1" changes and only "g1" is notified', ({ ids, subscription }) => { - ids.set('g1', 'id-1b'); - - act(() => subscription.notify(['g1'])); - }) - .then('only the reader of "g1" should re-render, with the new ID', ({ renders }) => { - expect(renders.get('g1')).toStrictEqual(['id-1', 'id-1b']); - expect(renders.get('g2')).toStrictEqual(['id-2', 'id-2b']); - expect(renders.get(undefined)).toStrictEqual([undefined]); - }); - }, - JestGlobals -); diff --git a/packages/component/src/providers/TranscriptFocus/useGroupDescendantIdByGroupKey.ts b/packages/component/src/providers/TranscriptFocus/useGroupDescendantIdByGroupKey.ts index beec8ab0ea..903e30300e 100644 --- a/packages/component/src/providers/TranscriptFocus/useGroupDescendantIdByGroupKey.ts +++ b/packages/component/src/providers/TranscriptFocus/useGroupDescendantIdByGroupKey.ts @@ -1,13 +1,12 @@ -import useKeyedSubscriptionValue from '../../hooks/internal/keyedSubscription/useKeyedSubscriptionValue'; +import { useMemo } from 'react'; + +import getGroupDescendantId from './private/getGroupDescendantId'; import useTranscriptFocusContext from './private/useContext'; -/** - * Returns the ID of the group's active descendant element, for `aria-activedescendant`, or `undefined` without a group key. - * - * Re-renders when `` notifies that the ID of this group changed. - */ +/** Returns the ID of the group for `aria-activedescendant`, or `undefined` without a group key. */ export default function useGroupDescendantIdByGroupKey(groupKey?: string | undefined): readonly [string | undefined] { - const { getGroupDescendantIdByGroupKey, subscribeGroupDescendantId } = useTranscriptFocusContext(); + const { prefix } = useTranscriptFocusContext(); - return useKeyedSubscriptionValue(subscribeGroupDescendantId, groupKey, getGroupDescendantIdByGroupKey); + // Derived from values only: a change of `prefix` or `groupKey` re-renders the caller. + return useMemo(() => Object.freeze([getGroupDescendantId(prefix, groupKey)] as const), [groupKey, prefix]); } From e0a12a093e4d1c8033ee56fa9da6573411742046 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 22 Oct 2026 21:00:50 -0700 Subject: [PATCH 18/18] Address review nits: private stores hook, getLogicalGrouping, one descendant ID helper, useMemo for stores --- .../ActivityLogicalGroupingComposer.ts | 6 ++-- .../private/useLogicalGroupingStores.ts | 15 ++++++++ .../TranscriptFocusComposer.tsx | 35 +++++++++---------- .../private/getDescendantId.ts | 8 +++++ .../private/getGroupDescendantId.ts | 3 -- .../useGroupDescendantIdByGroupKey.ts | 4 +-- 6 files changed, 45 insertions(+), 26 deletions(-) create mode 100644 packages/component/src/providers/ActivityLogicalGrouping/private/useLogicalGroupingStores.ts create mode 100644 packages/component/src/providers/TranscriptFocus/private/getDescendantId.ts delete mode 100644 packages/component/src/providers/TranscriptFocus/private/getGroupDescendantId.ts diff --git a/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts b/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts index ddc498613c..c07b513975 100644 --- a/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts +++ b/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts @@ -1,5 +1,5 @@ import { reactNode, validateProps } from '@msinternal/botframework-webchat-react-valibot'; -import { createElement, memo, useCallback, useMemo, useState } from 'react'; +import { createElement, memo, useCallback, useMemo } from 'react'; import { object, optional, pipe, readonly, type InferInput } from 'valibot'; import createKeyedStore from '../../hooks/internal/createKeyedStore'; @@ -21,8 +21,8 @@ const ActivityLogicalGroupingComposer = (props: ActivityLogicalGroupingComposerP const { children } = validateProps(activityLogicalGroupingComposerPropsSchema, props); // Both stores notify only the keys whose value changed. - const [logicalGroupingStore] = useState(() => createKeyedStore()); - const [logicalGroupKeyStore] = useState(() => createKeyedStore()); + const logicalGroupingStore = useMemo(() => createKeyedStore(), []); + const logicalGroupKeyStore = useMemo(() => createKeyedStore(), []); // Removes the activity from the group, unless it has already moved to another group. const leaveLogicalGrouping = useCallback( diff --git a/packages/component/src/providers/ActivityLogicalGrouping/private/useLogicalGroupingStores.ts b/packages/component/src/providers/ActivityLogicalGrouping/private/useLogicalGroupingStores.ts new file mode 100644 index 0000000000..13e5ceeafe --- /dev/null +++ b/packages/component/src/providers/ActivityLogicalGrouping/private/useLogicalGroupingStores.ts @@ -0,0 +1,15 @@ +import { + useActivityLogicalGroupingContext, + type ActivityLogicalGroupingContextType +} from './ActivityLogicalGroupingContext'; + +/** + * For keyboard navigation, which reads the groups of other activities at event time. + * To render from a group key, use `useLogicalGroupKey()`, which subscribes. + */ +export default function useLogicalGroupingStores(): Pick< + ActivityLogicalGroupingContextType, + 'logicalGroupKeyStore' | 'logicalGroupingStore' +> { + return useActivityLogicalGroupingContext(); +} diff --git a/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.tsx b/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.tsx index 85237df11b..3db4682857 100644 --- a/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.tsx +++ b/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.tsx @@ -7,10 +7,10 @@ import usePrevious from '../../hooks/internal/usePrevious'; import useStateRef from '../../hooks/internal/useStateRef'; import useValueRef from '../../hooks/internal/useValueRef'; import scrollIntoViewWithBlockNearest from '../../Utils/scrollIntoViewWithBlockNearest'; -import { useActivityLogicalGroupingContext } from '../ActivityLogicalGrouping/private/ActivityLogicalGroupingContext'; +import useLogicalGroupingStores from '../ActivityLogicalGrouping/private/useLogicalGroupingStores'; import useRenderingActivityKeys from '../RenderingActivities/useRenderingActivityKeys'; import TranscriptFocusContext, { type TranscriptFocusContextType } from './private/Context'; -import getGroupDescendantId from './private/getGroupDescendantId'; +import getDescendantId from './private/getDescendantId'; type TranscriptFocusComposerProps = Readonly<{ children?: ReactNode | undefined; @@ -30,18 +30,17 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp const [renderingActivityKeys] = useRenderingActivityKeys(); const [, setRawFocusedActivityKey, rawFocusedActivityKeyRef] = useStateRef(); // Keyboard navigation reads arbitrary activities and groups at event time and renders nothing from them: it does not subscribe. - const { logicalGroupKeyStore, logicalGroupingStore } = useActivityLogicalGroupingContext(); + const { logicalGroupKeyStore, logicalGroupingStore } = useLogicalGroupingStores(); const getGroupKeyByActivityKey = logicalGroupKeyStore.get; - // The logical grouping carries `isCollapsed`, as the group state did. - const getGroupState = logicalGroupingStore.get; + const getLogicalGrouping = logicalGroupingStore.get; const getGroupBoundaries = useCallback( (groupKey: string): readonly [string | undefined, string | undefined] => { - const activityKeys = getGroupState(groupKey)?.activityKeys ?? []; + const activityKeys = getLogicalGrouping(groupKey)?.activityKeys ?? []; return Object.freeze([activityKeys.at(0), activityKeys.at(-1)] as const); }, - [getGroupState] + [getLogicalGrouping] ); // As we need to use IDREF for `aria-activedescendant`, @@ -49,12 +48,12 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp const prefix = useMemo(() => uniqueId(3), []); const getDescendantIdByActivityKey: (activityKey?: string) => string | undefined = useCallback( - (activityKey?: string) => activityKey && `webchat__transcript-focus-${prefix}__activity-${activityKey}`, + (activityKey?: string) => getDescendantId(prefix, 'activity', activityKey), [prefix] ); const getGroupDescendantIdByGroupKey: (groupKey?: string) => string | undefined = useCallback( - (groupKey?: string) => getGroupDescendantId(prefix, groupKey), + (groupKey?: string) => getDescendantId(prefix, 'group', groupKey), [prefix] ); @@ -138,7 +137,7 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp } const groupKey = typeof activityKey === 'string' && getGroupKeyByActivityKey(activityKey); - const groupState = groupKey && getGroupState(groupKey); + const groupState = groupKey && getLogicalGrouping(groupKey); // shortcut for collapsed group // when collapsed, we do not want to focus on the group or scroll it into view @@ -165,7 +164,7 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp focusedActivityKeyRef, getDescendantIdByActivityKey, getGroupKeyByActivityKey, - getGroupState, + getLogicalGrouping, handleFocus, rawFocusedActivityKeyRef, renderingActivityKeysRef, @@ -200,7 +199,7 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp (direction: 'up' | 'down') => { const { current: currentGroupKey } = rawFocusedActivityGroupKeyRef; const { current: orderedActivityKeys } = renderingActivityKeysRef; - const currentGroupState = getGroupState(currentGroupKey); + const currentGroupState = getLogicalGrouping(currentGroupKey); const [firstActivityKey, lastActivityKey] = getGroupBoundaries(currentGroupKey); if (direction === 'down') { @@ -233,7 +232,7 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp const prevActivityKey = orderedActivityKeys[currentIndex - 1]; const prevGroupKey = getGroupKeyByActivityKey(prevActivityKey); const prevGroupState = - prevGroupKey && prevGroupKey !== currentGroupKey ? getGroupState(prevGroupKey) : undefined; + prevGroupKey && prevGroupKey !== currentGroupKey ? getLogicalGrouping(prevGroupKey) : undefined; // Previous activity is in a different collapsed group if (prevGroupState?.isCollapsed) { @@ -250,7 +249,7 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp focusByGroupKey, getGroupBoundaries, getGroupKeyByActivityKey, - getGroupState, + getLogicalGrouping, rawFocusedActivityGroupKeyRef, renderingActivityKeysRef ] @@ -270,7 +269,7 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp // Moving to a different group const [firstActivityKey, lastActivityKey] = getGroupBoundaries(nextGroupKey); - const nextGroupState = getGroupState(nextGroupKey); + const nextGroupState = getLogicalGrouping(nextGroupKey); if (direction === 'down' && firstActivityKey === nextActivityKey) { // Going down into a new group - always focus header first @@ -284,7 +283,7 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp focusByActivityKey(nextActivityKey, true); } }, - [focusByActivityKey, focusByGroupKey, getGroupBoundaries, getGroupKeyByActivityKey, getGroupState] + [focusByActivityKey, focusByGroupKey, getGroupBoundaries, getGroupKeyByActivityKey, getLogicalGrouping] ); // Helper function to handle delta > 1 navigation @@ -353,7 +352,7 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp // Handle navigation from a focused activity const groupKey = getGroupKeyByActivityKey(focusedActivityKey); const [firstActivityKey, lastActivityKey] = getGroupBoundaries(groupKey); - const groupState = groupKey ? getGroupState(groupKey) : undefined; + const groupState = groupKey ? getLogicalGrouping(groupKey) : undefined; // In group special cases if (groupKey) { @@ -389,7 +388,7 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp focusedActivityKeyRef, getGroupBoundaries, getGroupKeyByActivityKey, - getGroupState, + getLogicalGrouping, getNextActivityLinear, handleDeltaNavigation, handleGroupTransition, diff --git a/packages/component/src/providers/TranscriptFocus/private/getDescendantId.ts b/packages/component/src/providers/TranscriptFocus/private/getDescendantId.ts new file mode 100644 index 0000000000..39bdd2688c --- /dev/null +++ b/packages/component/src/providers/TranscriptFocus/private/getDescendantId.ts @@ -0,0 +1,8 @@ +/** Returns the ID of the activity or group for `aria-activedescendant`, or `undefined` without a key. */ +export default function getDescendantId( + prefix: string, + type: 'activity' | 'group', + key?: string | undefined +): string | undefined { + return key && `webchat__transcript-focus-${prefix}__${type}-${key}`; +} diff --git a/packages/component/src/providers/TranscriptFocus/private/getGroupDescendantId.ts b/packages/component/src/providers/TranscriptFocus/private/getGroupDescendantId.ts deleted file mode 100644 index 8eb587b196..0000000000 --- a/packages/component/src/providers/TranscriptFocus/private/getGroupDescendantId.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default function getGroupDescendantId(prefix: string, groupKey?: string | undefined): string | undefined { - return groupKey && `webchat__transcript-focus-${prefix}__group-${groupKey}`; -} diff --git a/packages/component/src/providers/TranscriptFocus/useGroupDescendantIdByGroupKey.ts b/packages/component/src/providers/TranscriptFocus/useGroupDescendantIdByGroupKey.ts index 903e30300e..530dee7b26 100644 --- a/packages/component/src/providers/TranscriptFocus/useGroupDescendantIdByGroupKey.ts +++ b/packages/component/src/providers/TranscriptFocus/useGroupDescendantIdByGroupKey.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react'; -import getGroupDescendantId from './private/getGroupDescendantId'; +import getDescendantId from './private/getDescendantId'; import useTranscriptFocusContext from './private/useContext'; /** Returns the ID of the group for `aria-activedescendant`, or `undefined` without a group key. */ @@ -8,5 +8,5 @@ export default function useGroupDescendantIdByGroupKey(groupKey?: string | undef const { prefix } = useTranscriptFocusContext(); // Derived from values only: a change of `prefix` or `groupKey` re-renders the caller. - return useMemo(() => Object.freeze([getGroupDescendantId(prefix, groupKey)] as const), [groupKey, prefix]); + return useMemo(() => Object.freeze([getDescendantId(prefix, 'group', groupKey)] as const), [groupKey, prefix]); }