Skip to content

Keep the transcript when a React Activity shows Web Chat again - #6049

Merged
Eugene (OEvgeny) merged 31 commits into
mainfrom
OEvgeny/fix-activity-graph-catch-up
Nov 13, 2026
Merged

Eugene (OEvgeny) merged 31 commits into
mainfrom
OEvgeny/fix-activity-graph-catch-up

Conversation

@OEvgeny

@OEvgeny Eugene (OEvgeny) commented Nov 12, 2026 •

Copy link
Copy Markdown
Collaborator

Follow-up to #6040 (merged). When a React 19.2 <Activity> shows Web Chat again after its store changed while hidden, development builds throw and Web Chat unmounts.

Changelog Entry

Fixed

  • Fixed development builds throwing when a React 19.2 <Activity> shows Web Chat again, in PR #6049, by @OEvgeny

Description

A hidden React 19.2 <Activity> runs effect cleanups, so the graph has no subscribers and stops following the store. Showing Web Chat with a new element renders before effects subscribe again, and that render read a stale graph.

Design

  • Catch up on read, not follow the store always: an unmounted graph still does not stay subscribed. Same state until the store activities change (stable snapshot).
  • prevActivities is set before act(), whose middleware reads getState(): an act() that throws is no longer retried (and rethrown) on every later store change.

Specific Changes

  • createGraphFromStore.ts: catch up in getState() without subscribers

  • createGraphFromStore.spec.ts: catch-up scenario, plus a read without subscribers

  • react19/activity.hideShow.transcript.html: hide, add a message to the store, show a new element: both messages

  • react19/activity.hideShow.transcript.sameElement.html: same, showing the same element

  • I have added tests and executed them locally

  • I have added .changelog/<PR#>-<slug>.md

  • I have updated documentation

Review Checklist

This section is for contributors to review your work.

  • Accessibility reviewed (tab order, content readability, alt text, color contrast)
  • Browser and platform compatibilities reviewed
  • CSS styles reviewed (minimal rules, no z-index)
  • Documents reviewed (docs, samples, live demo)
  • Internationalization reviewed (strings, unit formatting)
  • package.json and package-lock.json reviewed
  • Security reviewed (no data URIs, check for nonce leak)
  • Tests reviewed (coverage, legitimacy)

Eugene Olonov added 29 commits November 9, 2026 15:27
React 18 and later use their own hook. React 16/17 use a fallback that
subscribes in one layout effect, which runs again only when subscribe changes.
useSelector(), <GraphProvider> and useKeyedStoreValue() now use the ponyfill.
<GraphProvider> reads the graph and the Redux activities as one snapshot,
instead of a store subscription while rendering plus two layout effects. It
follows the store through the batched subscription of useSelector(), so React
16/17 re-render it in the same batch as the selectors.

Selectors move to module scope, so getSnapshot() stays the same between renders.
createGraphFromStore() subscribed to the store when called and never
unsubscribed. <GraphProvider> calls it while rendering, so every mount, and
every render that never committed (e.g. the first one of <StrictMode>), left
a store subscription behind. The graph now subscribes on its first subscriber,
catches up with the store, and unsubscribes after its last one.
…ring

<SuggestedActionsComposer> dispatched setRawState() while rendering. With
useSyncExternalStore() on React 19, that dispatch notified components with a
pending update, and React logged "Cannot update a component while rendering
a different component". Dispatch from layout effects instead: the store still
has the suggested actions by the time they show.
The page puts React 18 in window.React, and its default variant rendered with
the bundled React DOM 16. React DOM 16 has no useSyncExternalStore(). The
Fluent variants already render with createRoot() of React DOM 18.
Dictation progress sets the dictate state in Redux and dictateAbortable in a
useState() of <Composer>. With useSyncExternalStore() on React 18 and later, the
Redux state rendered first, at sync priority, and the microphone button was
disabled while dictating until dictateAbortable rendered in the next task.
dictateAbortable is now in a keyed store read with useSyncExternalStore(), so
both render together.
The mock dispatched its events inside start(), which react-dictate-button calls
from an effect. On React 18 and later, "starting" and "dictating" then render in
the same frame, unlike with a browser's recognizer, and the forced-colors
dictation snapshots differed by a few anti-aliased pixels.
The action sink sets the suggested actions while the store dispatches the
incoming activity. With useSyncExternalStore() on React 18 and later, the
activity rendered at sync priority and the suggested actions, a useState() of
<SuggestedActionsComposer>, one task later. The suggested actions are now in a
store read with useSyncExternalStore(), so both render together.
Waiting for one task did not always paint "starting" first: under load, a
forced-colors dictation snapshot differed by a few pixels again (1 of about 40
runs).
When the host sets window.React but not window.ReactDOM, the IIFE bundle renders
with its React DOM 16, which cannot run React's useSyncExternalStore(). Hide it
from window.React there, so the ponyfill falls back as with React 16/17, like
before. typingIndicator.scroll.html covers this setup again.
…ribers, as when an Activity shows Web Chat again
The graph follows the store only while it has subscribers. A hidden React 19.2
<Activity> runs the cleanup of effects, so the graph stops following the store;
showing Web Chat again with a new element renders before the effects subscribe
again, and read a graph that missed the changes made while hidden. The
development check of useActivities() threw.

getState() now catches up with the store when the graph has no subscribers. It
returns the same state until the store activities change.
…tore' into OEvgeny/fix-activity-graph-catch-up
@OEvgeny
Eugene (OEvgeny) marked this pull request as ready for review November 13, 2026 00:32

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

After an unsubscribed store update, calling graph.act() can throw a nested-transaction error and prematurely advance the activity cursor, causing later reads to skip pending updates.

1 open issue
What changed in this PR

This follow-up to #6040 refreshes Web Chat’s graph after store changes made while React <Activity> hides it, preventing stale transcript reads when it becomes visible again.

Changes:

  • Catch up on graph reads without keeping unsubscribed graphs connected to the store.
  • Add graph-level and browser regression coverage for hide/show behavior with new and reused elements.
  • Document the development-build fix in the changelog.
File Description
packages/​core/​src/​graph/​createGraphFromStore.ts Adds catch-up reads and advances activity tracking before transactions.
packages/​core/​src/​graph/​createGraphFromStore.spec.ts Checks unsubscribed catch-up and stable repeated snapshots.
.changelog/​6049-fix-activity-graph-catch-up.md Records the React Activity fix.
__tests__/​html2/​react19/​activity.hideShow.transcript.sameElement.html Checks transcript preservation when reusing the same element.
__tests__/​html2/​react19/​activity.hideShow.transcript.html Checks transcript preservation when showing a new element.

Review effort: Balanced

// Catches up with changes made while the graph has no subscribers, e.g. when a hidden <Activity> (React 19.2+) ran the
// cleanup of effects and renders before subscribing again. Returns the same state until the store activities change.
graph.getState = () => {
unsubscribeFromStore || handleStoreChange();

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Confirmed: the new spec scenario (unsubscribed → dispatch → graph.act() → getState()) threw "Another transaction is ongoing" and then lost the activity. Fixed in 7edbda4: act() now catches up before the caller's transaction, reads inside a transaction no longer catch up, and prevActivities advances only after the catch-up applied. Web Chat does not reach this today: createGraphFromStore is the only caller of graph.act() and the graph is not exposed to hosts.

Reads inside a transaction no longer catch up with the store, so act() on a graph without subscribers does not throw "Another transaction is ongoing" and lose the activities. prevActivities advances only after the catch-up applied.
@OEvgeny
Eugene (OEvgeny) merged commit 720f68f into main Nov 13, 2026
46 checks passed
@OEvgeny
Eugene (OEvgeny) deleted the OEvgeny/fix-activity-graph-catch-up branch November 13, 2026 02:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants