Summary
PDChatContainer wraps the host application's entire page (ChildContent) around the chat UI. Certain DockMode transitions cause Blazor to fully dispose and reconstruct every component instance inside ChildContent instead of diffing/patching it — meaning any in-memory state in the host page (unsaved form input, editor buffers, scroll position, expanded rows, loaded data, etc.) is silently lost whenever the chat is docked, undocked, or restored from minimized.
This was surfaced via a downstream MagicSuite bug report where opening Merlin chat while editing a macro in Report Studio cleared the editor and execution results — but the bug is generic to PDChatContainer and reproduces with any ChildContent, in any host app.
Reproduction
- Host
PDChatContainer around some stateful content (e.g. a text box with typed text, or any component holding in-memory state not yet persisted).
- Open the chat panel and dock it to a side (
Left or Right).
- Minimize the chat, or move it back to a corner / full screen.
- Observe: the state you set in step 1 is gone, and (if you have render/lifecycle logging) every component under
ChildContent re-ran OnInitialized/constructed fresh — the whole subtree was torn down and rebuilt, not re-rendered.
The same happens switching directly between Left and Right while already docked.
Root cause
PDChatContainer.razor renders ChildContent differently depending on DockMode:
@if (IsSplitMode)
{
<div class="pdchat-container-wrapper">
<PDSplitter @key="@($"chat-splitter-{ChatService.DockMode}")" ...>
@if (ChatService.DockMode == PDChatDockMode.Left)
{
<PDSplitPanel @key="@($"chat-panel-{ChatService.DockMode}")" ...>
...
</PDSplitPanel>
<PDSplitPanel @key="@($"content-panel-{ChatService.DockMode}")" ...>
@ChildContent
</PDSplitPanel>
}
else
{
<PDSplitPanel @key="@($"content-panel-{ChatService.DockMode}")" ...>
@ChildContent
</PDSplitPanel>
<PDSplitPanel @key="@($"chat-panel-{ChatService.DockMode}")" ...>
...
</PDSplitPanel>
}
</PDSplitter>
</div>
}
else
{
<div class="pdchat-container-wrapper">
@ChildContent
<CascadingValue Value="this" Name="ChatContainer">
@ChatContent
</CascadingValue>
</div>
}
IsSplitMode (PDChatContainer.razor.cs) is:
internal bool IsSplitMode => ChatService.DockMode is PDChatDockMode.Left or PDChatDockMode.Right;
Two distinct problems here, both causing the same symptom:
1. Top-level branch swap. Minimized, the four corner modes, and FullScreen all hit the else branch, where ChildContent is a direct child of a plain div. Left/Right hit the if branch, where ChildContent is nested two levels deeper inside PDSplitter → PDSplitPanel. Because ChildContent occupies a structurally different parent chain in each branch, Blazor's diff algorithm can't reconcile the old render tree with the new one when IsSplitMode flips — it has to dispose every component under the old branch and construct new ones for the new branch. @key does not help here: keyed matching only reconciles siblings compared within the same diff pass, and the two branches never coexist in the same pass.
2. DockMode-keyed content panel. Even while staying inside the if branch, the content PDSplitPanel's @key embeds ChatService.DockMode itself (content-panel-{ChatService.DockMode}). So Left → Right produces a different key (content-panel-Left vs content-panel-Right), forcing Blazor to treat it as a brand-new component and remount ChildContent again — even though the content itself has no logical reason to be recreated, only the chat panel's side changed.
OnInternalDockModeChanged calls StateHasChanged() unconditionally on every DockMode change, so this branch re-evaluation happens on every dock/undock/restore:
internal async Task OnInternalDockModeChanged(PDChatDockMode newDockMode)
{
ChatService.DockMode = newDockMode;
StateHasChanged();
...
}
Why this isn't an edge case
Any consumer whose IChatService sets RestoreMode or PreferredDockMode to Left/Right (a natural choice — that's the "docked" position) will cross the IsSplitMode boundary on the very first open/close of the chat, since restoring from Minimized goes straight to a split mode. DumbChatService's own defaults (PreferredDockMode = Right, RestoreMode = BottomRight) mean this doesn't fire immediately in the demo app, but it does as soon as the user explicitly docks to a side even once and then minimizes/restores.
Proposed fix
Two independent changes, ideally both:
Part A — stable keys (low risk). Change the PDSplitPanel @keys from DockMode-embedded values to stable role names ("chat-panel" / "content-panel"), and the PDSplitter's key to a constant. Since both panels remain siblings under the same PDSplitter instance, Blazor's keyed diff will move them when Left/Right order flips, instead of destroying and recreating them.
Part B — eliminate the branch swap (needs visual verification). Stop conditionally choosing between "ChildContent inside PDSplitter/PDSplitPanel" and "ChildContent directly in a div". Always mount PDSplitter/PDSplitPanel regardless of DockMode, so ChildContent's parent chain never changes shape. For non-split modes, collapse the chat pane to Size="0"/MinSize="0" with the gutter hidden, and let PDChat's existing fixed-position CSS (dock-bottom-right, dock-top-left, etc., in GetDockModeClasses()) handle the corner/minimized/fullscreen visuals instead of a DOM restructure. This needs confirming that a position: fixed descendant still escapes correctly out of a zero-width PDSplitPanel (no ancestor should be setting transform/filter/perspective, which would trap it) — worth a pass across all 8 DockMode values before merging.
Environment
- Package version: 10.0.155 (current published version at time of report)
- Affected file:
PanoramicData.Blazor/PDChatContainer.razor (+ PDChatContainer.razor.cs)
- Reproduces on both server-side and (presumably) WASM Blazor hosting models — the bug is in the render-tree diffing, not the hosting model
Summary
PDChatContainerwraps the host application's entire page (ChildContent) around the chat UI. CertainDockModetransitions cause Blazor to fully dispose and reconstruct every component instance insideChildContentinstead of diffing/patching it — meaning any in-memory state in the host page (unsaved form input, editor buffers, scroll position, expanded rows, loaded data, etc.) is silently lost whenever the chat is docked, undocked, or restored from minimized.This was surfaced via a downstream MagicSuite bug report where opening Merlin chat while editing a macro in Report Studio cleared the editor and execution results — but the bug is generic to
PDChatContainerand reproduces with anyChildContent, in any host app.Reproduction
PDChatContaineraround some stateful content (e.g. a text box with typed text, or any component holding in-memory state not yet persisted).LeftorRight).ChildContentre-ranOnInitialized/constructed fresh — the whole subtree was torn down and rebuilt, not re-rendered.The same happens switching directly between
LeftandRightwhile already docked.Root cause
PDChatContainer.razorrendersChildContentdifferently depending onDockMode:IsSplitMode(PDChatContainer.razor.cs) is:Two distinct problems here, both causing the same symptom:
1. Top-level branch swap.
Minimized, the four corner modes, andFullScreenall hit theelsebranch, whereChildContentis a direct child of a plaindiv.Left/Righthit theifbranch, whereChildContentis nested two levels deeper insidePDSplitter→PDSplitPanel. BecauseChildContentoccupies a structurally different parent chain in each branch, Blazor's diff algorithm can't reconcile the old render tree with the new one whenIsSplitModeflips — it has to dispose every component under the old branch and construct new ones for the new branch.@keydoes not help here: keyed matching only reconciles siblings compared within the same diff pass, and the two branches never coexist in the same pass.2. DockMode-keyed content panel. Even while staying inside the
ifbranch, the contentPDSplitPanel's@keyembedsChatService.DockModeitself (content-panel-{ChatService.DockMode}). SoLeft → Rightproduces a different key (content-panel-Leftvscontent-panel-Right), forcing Blazor to treat it as a brand-new component and remountChildContentagain — even though the content itself has no logical reason to be recreated, only the chat panel's side changed.OnInternalDockModeChangedcallsStateHasChanged()unconditionally on everyDockModechange, so this branch re-evaluation happens on every dock/undock/restore:Why this isn't an edge case
Any consumer whose
IChatServicesetsRestoreModeorPreferredDockModetoLeft/Right(a natural choice — that's the "docked" position) will cross theIsSplitModeboundary on the very first open/close of the chat, since restoring fromMinimizedgoes straight to a split mode.DumbChatService's own defaults (PreferredDockMode = Right,RestoreMode = BottomRight) mean this doesn't fire immediately in the demo app, but it does as soon as the user explicitly docks to a side even once and then minimizes/restores.Proposed fix
Two independent changes, ideally both:
Part A — stable keys (low risk). Change the
PDSplitPanel@keys from DockMode-embedded values to stable role names ("chat-panel"/"content-panel"), and thePDSplitter's key to a constant. Since both panels remain siblings under the samePDSplitterinstance, Blazor's keyed diff will move them whenLeft/Rightorder flips, instead of destroying and recreating them.Part B — eliminate the branch swap (needs visual verification). Stop conditionally choosing between "
ChildContentinsidePDSplitter/PDSplitPanel" and "ChildContentdirectly in adiv". Always mountPDSplitter/PDSplitPanelregardless ofDockMode, soChildContent's parent chain never changes shape. For non-split modes, collapse the chat pane toSize="0"/MinSize="0"with the gutter hidden, and letPDChat's existing fixed-position CSS (dock-bottom-right,dock-top-left, etc., inGetDockModeClasses()) handle the corner/minimized/fullscreen visuals instead of a DOM restructure. This needs confirming that aposition: fixeddescendant still escapes correctly out of a zero-widthPDSplitPanel(no ancestor should be settingtransform/filter/perspective, which would trap it) — worth a pass across all 8DockModevalues before merging.Environment
PanoramicData.Blazor/PDChatContainer.razor(+PDChatContainer.razor.cs)