Two chats on one page — why a chat view's JS may never ask the document

ThreadChatView is multi-instance by construction. It carries its own _instanceId, it is rendered per ThreadNodeType.ThreadChatArea layout area, and it renders .thread-chat-widget itself. The welcome screen holds two of them — the middle chat and the side panel — and any page can hold one, two, or none.

That single fact decides what its JS interop module is allowed to do, and it is the whole of plugins#2243.

The defect, and why it looked like a CSS problem

The agent-picker popup is position: fixed so it can escape the chat's overflow clip and paint over the sticky header, which means its left/width and its open direction have to be measured in JS rather than expressed in CSS. anchorChatPopup() did that measurement from two document-wide lookups:

const w = document.querySelector('.thread-chat-widget');
const anchor = document.querySelector('.thread-chat-input-content')
    || document.querySelector('.thread-chat-input-area');

…and the call site passed no element at all. document.querySelector answers the first match in document order, identically for every caller. So:

page instances what a user sees
welcome screen middle chat + side panel both agent fields open the popup at the middle chat's composer
any other page side panel only the first match is its own composer, so it anchors correctly

The second row is what made this expensive to read. A defect that is correct on every page but one invites a CSS or stacking-context explanation, and there is none to find: the markup, the styles and the Fluent dialog were all doing exactly what they were told. The bug was that the instruction addressed the wrong element.

The sibling shape: module-level state in a per-instance module

A .razor.js file is loaded once per page and shared by every instance of its component. So a binding at module scope is not "the component's state", it is one component's state that the others also read and write. The same module carried two:

A third case sits beside that one, and moving state onto the element is what made it addressable rather than what caused it: a detached DOM element does not stop its MediaStream. Stopping tracks only ever happened inside stopDictation's flush, so a component removed mid-recording — closing the side panel while it listened — left the microphone live with no UI able to stop it. Teardown therefore needs its own non-transcribing release (cancelDictation, invoked from DisposeAsync before the module reference goes): a component that is gone has nobody to hand audio to, and reusing stopDictation and discarding its result would still run the whole encode for a listener that no longer exists.

The rule

A per-component JS module receives the element it operates on; it never goes looking for one.

Why no render test can see either shape

bUnit runs no layout engine and executes no JavaScript. There is no measured position to assert on, and the module never runs. The shape in the source is the defect, which is why the control is a source scan (ComponentJsIsInstanceScopedGuard) rather than a rendered assertion. Stated plainly so the next reader does not go looking for the render test that "should" exist.

The census, and the ratchet

The fix was applied to the module that was reported. The shape was then counted across the whole repository, because a fix at one of N sites is how this class recurs:

modules carrying a shape
.razor.js under src/ 14 9 modules, 16 (module, shape) pairs — 8 with a document-wide element lookup, 8 with a module-level state binding
the same in the platform repo's src/ 0

ThreadChatView's two were both of them and are fixed. The remaining sites are named one per line in src/MeshWeaver.Blazor.Views.Test/component-js-instance-scope.allow, each with a reason, and the guard treats that file as shrink-only: a new module carrying either shape fails, and an entry whose module no longer carries the shape fails as stale until the line is deleted. So the list cannot silently outlive what it describes, and the debt is counted rather than invisible.

🚨 A debt entry is NOT an adjudication. It means only "this carries the shape and nobody has yet answered whether it matters". Adjudicating one is a single question — can two of this component be on a page at once? If yes, scope it and delete the line; if no, the entry becomes singleton, an explicit exemption that keeps its reason attached. Two entries answer the question loudly in advance: Monaco editors and markdown views are certainly multi-instance, and MonacoEditorView / NotebookEditorView each key a module-level new Map() by instance id. Those are unfiled defects of the same class, not exemptions. The status column exists because the first version of this file told a reader to delete an entry once adjudicated a singleton — which would have made the guard report that module as unlisted, so following the file's own advice reddened the build.

🚨 And the scan states its own limits, because a regex cannot decide mutability. It reads a top-level let/var, and a top-level const bound to a mutable container or an object/array literal — that last clause is what makes const state = { recorder: null } a failure rather than an evasion, and widening to it is what took the module-state count from 5 to 8. It does not see a const holding a class instance or a call result, a closure over a variable inside a top-level function, a property written onto an imported binding, or state parked on window. It also cannot tell a never-written lookup table from a state bag, so two such tables (LSP_TO_MONACO_KIND, ThemeModes) are entries whose reason says exactly that. The first version of this guard documented itself as enforcing "module-level mutable state" while reading only let/var — a stated invariant wider than the scan, which is the same class of defect as the bug it guards.

What this does not establish

Reconnecting…
The connection to the server was interrupted. Trying to restore it…
Trying again…
The connection could not be restored. Reloading the page…
The server was updated. Reloading the page to pick up the latest version.