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:
let _popupResizeBound = false;— only the first instance to open a popup ever bound theresize/scrolllistener, and the closure it bound re-queried the document, so that listener kept repositioning the first widget no matter which instance was open. A fix to the lookups alone would have landed and then been undone by the next resize.let _rec = null, _chunks = [], _stream = null;— the dictation recorder. A second chat's mic button overwrote the first'sMediaRecorderand itsMediaStream; the displaced stream's tracks were never stopped, so the microphone stayed open with no UI saying so, and whichever composer pressed stop received the other one's audio.
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.
- The component exposes an
ElementReferenceon its own root (.thread-chat-container) and passes it across the interop boundary. Every lookup inside the module isroot.querySelector(…). - Per-instance state lives on that element or on a handle the component disposes, never at
module scope.
anchorChatPopup(root)returns{ reposition, dispose }: the component creates it when it opens a popup, re-measures through it on later renders, and callsdispose()when the popup closes or the component is torn down — so no closure outlives the element it measures, and a second instance neither steals the binding nor fights it. Dictation keys its recorder on the root element for the same reason, and claims it before the async flush so a second stop cannot resolve one recording twice. - The one legitimate exception is a genuine page singleton.
wwwroot/chatResizer.jsreaches for.layout.chat-visible— the portal shell, of which there is exactly one — and that is correct. The distinction is not "is it a.jsfile" but "can two of this component exist at once".
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
- The welcome screen was read from the component and its call site, not from a running page. Whether
it renders two
ThreadChatViewinstances or one instance plus a second element carrying the same classes was not confirmed — and it does not change the defect or the remedy, because a document-wide first-match selector cannot address the instance that called it either way. - The popup's resulting geometry is unverified by any automated test, here or before. The guard proves the lookups are scoped; it does not prove the measurement is right.
- The eight allow-listed document-scope sites were classified by the shape of their lookup, not by reading each component's mounting. The list is a census, not a verdict.
- No allow-listed module was read for whether its state binding is actually WRITTEN. The guard reads binding syntax; the entries record that, and nothing more.