Skip to content

feat(devtools): Register event subscriptions on-demand#27591

Open
Josmithr wants to merge 2 commits into
microsoft:mainfrom
Josmithr:devtools/register-subscriptions-on-demand
Open

feat(devtools): Register event subscriptions on-demand#27591
Josmithr wants to merge 2 commits into
microsoft:mainfrom
Josmithr:devtools/register-subscriptions-on-demand

Conversation

@Josmithr

Copy link
Copy Markdown
Contributor

Previously, devtools would (upon initialization) immediately register on-op event listeners on all provided root DDSs and immediately begin broadcasting data visualization messages on the window (regardless of whether or not the browser extension was running and listening).

This PR updates devtools to register these listeners lazily on demand. This leverages the existing laziness that was implemented for nested DDSs, such that we only register event listeners when we receive the GET_DATA_VISAUALIZATION message from the browser extension.

A corresponding CLOSE_DATA_VISUALIZATION message has been added, which the extension now uses to indicate when it is done needing data from the client. The client leverages this alongside simple ref-counting to unsubscribe DDS listeners when there is no longer interest in data.

  • Note: this PR does not address the potential listener leak that can occur if a consumer requests data events but fails to send the corresponding close message. But since the code previously never de-registered listeners once initially registered, this is still a strict improvement. We can follow up on this in the future as needed.

Note that there are no cross-layer compat issues here between the devtools client library and the extension.

client ↓ \ extension → Old extension (doesn't send CLOSE message) New extension (sends CLOSE message)
Old client (eager broadcast, no CLOSE handler) Baseline. Eager broadcasting of all reachable roots; subscriptions never released. Works as before. Compatible. Old core ignores the unrecognized CLOSE_DATA_VISUALIZATION. Still eager; view filters incoming DATA_VISUALIZATION by fluidObjectId and detaches handlers on unmount, so extra/stale broadcasts are harmless. No regression.
New client (on-demand, ref-counted, CLOSE handler) Compatible, strict improvement. Nothing broadcasts until the view sends GET_DATA_VISUALIZATION. Old view never sends CLOSE, so each subscribed node stays subscribed after collapse — subscription leak, but behavior is no worse than the Old-core baseline (which monitored everything unconditionally). Fully intended behavior. On-demand subscribe on expand/mount, balanced unsubscribe on collapse/unmount. Monitoring (and broadcasting) occurs only while a view is actively displaying an object. No leak (assuming proper view teardown).

@github-actions

Copy link
Copy Markdown
Contributor

Hi! Thank you for opening this PR. Want me to review it?

Based on the diff (538 lines, 9 files), I've queued these reviewers:

  • Correctness — logic errors, race conditions, lifecycle issues
  • Security — vulnerabilities, secret exposure, injection
  • API Compatibility — breaking changes, release tags, type design
  • Performance — algorithmic regressions, memory leaks
  • Testing — coverage gaps, hollow tests

How this works

  • Adjust the reviewer set by ticking/unticking boxes above. Reviewer toggles alone don't trigger anything.

  • Tick Start review below to dispatch the review fleet.

  • After review finishes, tick Start review again to request another run — it auto-resets after each dispatch.

  • This comment updates as new commits land; your reviewer selections are preserved.

  • Start review

@Josmithr Josmithr marked this pull request as ready for review June 23, 2026 23:39
@Josmithr Josmithr requested a review from a team as a code owner June 23, 2026 23:39
Copilot AI review requested due to automatic review settings June 23, 2026 23:39

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.

Copilot reviewed 9 out of 9 changed files in this pull request and generated no comments.

@github-actions

Copy link
Copy Markdown
Contributor

Bundle size comparison

Base commit: 83fe40c48e6d2c51839c3693ddba71066f45a1ec
Head commit: a072ce262a4667fec982b5f5c5370c393f6aae6c

Notable changes

No bundles changed by ≥ 500 bytes parsed.

Per-bundle deltas

@fluid-example/bundle-size-tests

  • azureClient.js: parsed 618933 → 618989 (+56), gzip 164786 → 164837 (+51)
  • odspClient.js: parsed 591767 → 591823 (+56), gzip 158878 → 158923 (+45)
  • aqueduct.js: parsed 525463 → 525498 (+35), gzip 140683 → 140718 (+35)
  • fluidFramework.js: parsed 391960 → 391981 (+21), gzip 111089 → 111106 (+17)
  • sharedTree.js: parsed 381347 → 381361 (+14), gzip 108485 → 108496 (+11)
  • containerRuntime.js: parsed 303813 → 303827 (+14), gzip 83188 → 83197 (+9)
  • sharedString.js: parsed 175984 → 175991 (+7), gzip 49445 → 49453 (+8)
  • experimentalSharedTree.js: parsed 160798 → 160798 (0), gzip 45804 → 45804 (0)
  • matrix.js: parsed 159845 → 159852 (+7), gzip 45411 → 45418 (+7)
  • loader.js: parsed 145221 → 145235 (+14), gzip 39052 → 39067 (+15)
  • odspDriver.js: parsed 104431 → 104452 (+21), gzip 32647 → 32657 (+10)
  • directory.js: parsed 66616 → 66623 (+7), gzip 18532 → 18540 (+8)
  • 748.js: parsed 58793 → 58793 (0), gzip 17826 → 17826 (0)
  • map.js: parsed 46709 → 46716 (+7), gzip 14310 → 14317 (+7)
  • odspPrefetchSnapshot.js: parsed 45650 → 45664 (+14), gzip 15276 → 15284 (+8)
  • 594.js: parsed 44493 → 44493 (0), gzip 13744 → 13744 (0)
  • summarizerDelayLoadedModule.js: parsed 30753 → 30753 (0), gzip 7767 → 7767 (0)
  • socketModule.js: parsed 26486 → 26493 (+7), gzip 7883 → 7891 (+8)
  • createNewModule.js: parsed 12480 → 12480 (0), gzip 4786 → 4786 (0)
  • summaryModule.js: parsed 3797 → 3797 (0), gzip 1860 → 1860 (0)
  • connectionState.js: parsed 724 → 724 (0), gzip 429 → 429 (0)
  • sharedTreeAttributes.js: parsed 666 → 673 (+7), gzip 431 → 442 (+11)
  • debugAssert.js: parsed 429 → 429 (0), gzip 299 → 299 (0)
  • FluidFramework-HashFallback.js: parsed 422 → 422 (0), gzip 316 → 316 (0)

@Josmithr Josmithr requested a review from jikim-msft June 24, 2026 20:10
@@ -341,8 +381,11 @@ export class DataVisualizerGraph
*
* A visual representation can be requested via {@link VisualizerNode.render}.
*
* Additionally, whenever the associated `ISharedObject` is updated (i.e. whenever its "op" event is emitted),
* an updated visual tree will be emitted via this object's {@link SharedObjectListenerEvents | "update" event}.
* Additionally, while at least one consumer is {@link VisualizerNode.subscribe | subscribed}, whenever the associated

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.

NIT: Should we repalce "whenever" to "when subscribed" mirroring the class-level JSDOC from above?

@jikim-msft

Copy link
Copy Markdown
Contributor

NIT: While renaming existing types to DataVisualizerEvents, there's a dangling reference to {@link SharedObjectListenerEvents | "update"} event at Line 511 of DataVisualization.ts. Might as well fix it here for consistency

@Josmithr

* Initialize data visualization monitoring immediately to ensure event listeners are set up
* and console logs appear even when devtools UI is not open.
*/
private async initializeDataVisualizationMonitoring(): Promise<void> {

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.

Removing the startup monitoring here — together with moving the op listener out of VisualizerNode's constructor and into subscribe() — means the client now only attaches an op listener (and therefore only broadcasts DataVisualization updates) for objects a consumer has explicitly subscribed to via GetDataVisualization.

I think that regresses the sidebar "container has changes" indicator. The only code path that emits reason: DataChanged is dataUpdateHandler, which fires off the DataVisualizerGraph "update" event — and after this change that event only fires while a VisualizerNode has an active subscription. But Menu.tsx drives the indicator purely from inbound DataVisualization messages with reason === DataChanged, while only ever sending GetContainerState, never GetDataVisualization:

[DataVisualization.MessageType]: async (untypedMessage) => {
const message = untypedMessage as DataVisualization.Message;
const containerKey = message.data.containerKey;
if (message.data.reason === DataVisualization.UpdateReason.DataChanged) {
const existingTimer = changeIndicatorTimers.get(containerKey);
if (existingTimer !== undefined) clearTimeout(existingTimer);
setContainersWithChanges(new Set([containerKey]));
const timer = setTimeout(() => {
setContainersWithChanges(
(prev) => new Set([...prev].filter((key) => key !== containerKey)),
);
setChangeIndicatorTimers(
(prev) => new Map([...prev].filter(([key]) => key !== containerKey)),
);
}, 1000);
setChangeIndicatorTimers((prev) => new Map(prev).set(containerKey, timer));
}
return true;
},

Since FluidHandleView (the detail panel) is the only sender of GetDataVisualization, no op listener exists for a container's objects unless the user has that specific object's detail view open — so the "has changes" indicator will no longer light up for anything the user hasn't drilled into. Previously this method rendered every root "to set up event listeners on all shared objects" at startup, so the indicator fired regardless.

The new DataVisualizerGraph test "Broadcasts updates while subscribed and stops after unsubscribe" actually captures the mechanism: an op emitted before subscribe() yields updateCount === 0. For the objects the menu cares about (which are never subscribed), that zero-broadcast path is exactly the regression.

Is this intended / handled in a follow-up? I didn't see the indicator called out in the compatibility matrix. If it's meant to keep working, it likely needs an always-on signal independent of the detail-panel subscription — e.g. the menu subscribing per container, or a lightweight "changed" notification decoupled from full visualization.

@Josmithr Josmithr Jun 24, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Interesting. I'd forgotten about that indicator - thanks for the callout. I wish it was easier to write integration tests for these scenarios 😟

So, I think this is a regression we probably want to take, though it is definitely worth considering how we should update the change indicator in response. My main reasons for this are:

  1. Performance - the fact that apps using devtools immediately pay a non-trivial cost in listening for DDS changes and broadcasting messages on the window (even when no one is listening) is a deterrent for using them at all. We were forced to disable the devtools (by default) in our text demo because the performance hit was so substantial.

  2. This issue already exists for any nested DDSs - in our visualizer graph (currently), we eagerly bind listeners and post updates upon devtools initialization, but only for the root DDSs declared in the input set. Any other DDSs referenced by handle already don't publish updates until the view (extension) requests them.
    Consider the following setup: devtools is configured with a single SharedDirectory which contains a single child item: a handle to a SharedTree. The corresponding application exclusively uses the SharedTree, and never directly changes its directory contents. Our change indicator would already never fire in this case.

Question for you: was the change indicator a requirement for anything? Accessibility maybe? Or was it just a nice-to-have UX improvement? If it wasn't a hard requirement, I personally think the performance considerations here are more valuable. To be clear: I really like this feature; I just personally think the performance implications are more important 😕

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