Repository navigation
fix: HtmlView honours Style/Class instead of silently dropping them - #1288
Conversation
HtmlView's non-clickable branch emitted the bare fragment with no wrapper,
so Style and Class — bound by BlazorView.BindData for every control and
public API on UiControl — were discarded unless the control happened to be
clickable. Controls.Html(x).WithStyle("width: 100%") did nothing at all: no
error, no log, no fallback. Same defect class as #742 (ButtonView/IconView/
BadgeView), which is why the fix and its tests live alongside those.
Live consequence: raw <svg> emitted via Controls.Html becomes a direct flex
child of its FluentStack and collapses to its intrinsic size; the obvious
author fix was the exact instruction being dropped.
The wrapper is CONDITIONAL on the author having declared Style or Class.
HtmlControl has no element of its own and ~400 call sites depend on that:
BlazorViewRegistry.FallbackHtml emits bare encoded text for every
unrecognised control, Northwind's GrowthPercentage emits an inline <span>,
the settings tabs emit several top-level siblings meant to be separate
gapped children of their stack, and Todo/Source/TodoLayoutAreas writes
flex: 1 onto the fragment's own root precisely because that root is the
direct flex child. Wrapping unconditionally would turn inline runs into
block boxes, collapse gapped siblings into one child and make that flex: 1
inert — and most of those call sites are in-mesh source that no build and
no test renders, so it would surface only in a running portal. Not one of
them sets Style or Class, so gating on the declaration leaves every one
byte-identical.
Gates on ViewModel.Style/Class (the declaration) rather than the bound
fields (the resolved values): either may be a JsonPointerReference that
lands after the first render, and gating on the resolved value would
restructure the DOM mid-flight. A resolved value can only be non-null when
the declaration was, so nothing is lost.
Tests, verified by falsification against the pre-fix markup: the two
positive tests fail without the fix, the two bare-fragment
backwards-compatibility guards pass with and without it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Fixes a rendering hole in the Blazor HtmlView so HtmlControl no longer silently drops author-provided Style/Class in the non-clickable branch, while preserving the existing “bare fragment” behavior when no styling was declared.
Changes:
- Update
HtmlView.razorto conditionally wrap the fragment in a<div>only whenStyleand/orClasswas declared, ensuring those attributes reach the DOM without breaking existing multi-root/inline fragment layouts. - Add renderer-level tests in
ControlStyleRenderingTestto prove the bug (style/class reach DOM) and guard backwards-compatibility (unstyled fragments remain bare, multi-root stays multi-root). - Document the “bare fragment by default; wrapper when styling is declared” contract for
HtmlControl, and add a What’s New entry describing the user-visible fix.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
src/MeshWeaver.Blazor/Components/HtmlView.razor |
Adds a HasStyling-gated wrapper so Style/Class are honored for non-clickable Html controls without changing existing unstyled fragment rendering. |
test/MeshWeaver.Hosting.Blazor.Test/ControlStyleRenderingTest.cs |
Adds positive + backwards-compatibility tests using the real HtmlRenderer to pin the regression and prevent unconditional wrapping. |
src/MeshWeaver.Layout/HtmlControl.cs |
Updates XML docs to clarify how/when HtmlControl renders as a bare fragment vs a wrapped box to carry styling. |
src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-13-content-blocks-no-longer-ignore-the-width-you-set.md |
Adds a What’s New note describing the fix and its user impact. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Test Results (shard 1)1 355 tests +65 1 355 ✅ +68 7m 26s ⏱️ +53s Results for commit ba87688. ± Comparison against base commit dba5eb8. This pull request removes 244 and adds 309 tests. Note that renamed tests count towards both.♻️ This comment has been updated with latest results. |
Test Results (shard 2)2 114 tests - 64 2 113 ✅ - 64 6m 24s ⏱️ - 2m 8s Results for commit ba87688. ± Comparison against base commit dba5eb8. This pull request removes 308 and adds 244 tests. Note that renamed tests count towards both.♻️ This comment has been updated with latest results. |
…trol
Self-review caught a false claim in the previous commit's reasoning. I had
asserted "not one call site sets Style or Class on an HtmlControl", then
found ~100 Controls.H2(...).WithStyle("margin: 0") sites and assumed they
were HtmlControls because Controls.Title is one. They are not:
Controls.Title(text, n) -> HtmlControl (emits raw <hN> markup)
Controls.H1..H6(data) -> LabelControl (Label(data).WithTypo(Hn))
H1..H6 render through Label.razor, which already applies Style="@Style" in
both branches, so those ~100 sites never had this bug and are untouched.
The test that exposed this failed with "Unable to cast LabelControl to
HtmlControl".
A balanced-paren scan for Controls.Html / Controls.Title / new HtmlControl
followed by a .WithStyle/.WithClass chain, across src/ samples/ content/
memex/, finds THREE real call sites:
samples/Todo/.../TodoLayoutAreas.cs:1717 Controls.Html(<textarea…>)
src/MeshWeaver.Graph/VersionLayoutArea.cs:153, 443 Controls.Title(…, 2)
All three were asking for a style that was being dropped and now get it.
Replaces the inaccurate doc paragraph, and adds three tests: the two
Controls.Title cases (styled -> wrapper, unstyled -> bare <h2>), plus
HeadingFactories_AreLabelControls_NotHtmlControls, which pins the
Title/H1..H6 distinction so re-implementing the heading factories on top of
Title fails loudly instead of silently widening the blast radius.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lass Copilot review: the remarks said HtmlControl "has no element of its own" without noting that the clickable branch has always wrapped the fragment to carry the click handler. Read literally, a consumer could conclude the control never introduces an element. Restates it as a default rather than an invariant: the bare fragment is what you get when the control is non-clickable AND has no Style/Class; a click action or a declared Style/Class each introduce exactly one <div>. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The defect
HtmlView.razor's non-clickable branch emitted the bare fragment with no wrapper element:StyleandClassare public API onUiControland are bound for every control byBlazorView.BindData(BlazorView.razor.cs:494-496) — so an author could set them and this viewsilently discarded them unless the control happened to be clickable. No error, no log, no fallback.
Controls.Html(x).WithStyle("width: 100%")did nothing.Same defect class as #742 (
ButtonView/IconView/BadgeView), which is why the fix and its testslive alongside those in
ControlStyleRenderingTest.Driving case: raw
<svg>emitted viaControls.Html(...)becomes a direct flex child of itsFluentStackand collapses toward its intrinsic size — the Reinsurance charts at ~6% of column width.The obvious author fix was the exact instruction being dropped, which made it look unfixable rather
than merely broken. This PR is the platform fix only — the charts themselves are being moved onto
framework chart controls separately (Reinsurance PR #52); nothing here chases that symptom.
Wrapping strategy: conditional, gated on the declaration
The fragment is wrapped in a
<div style class>only when the author declaredStyleorClass.Otherwise the bare fragment is emitted exactly as today.
Why not unconditional — consumer evidence
HtmlControlis the one control with no element of its own:Datais emitted as a raw fragment, and~416 call sites depend on that (334 in
src/, 82 insamples/+content/+memex/). Fourconcrete shapes in the tree break under an unconditional wrapper:
src/MeshWeaver.Blazor/BlazorViewRegistry.cs:214(FallbackHtml)samples/Northwind/.../LayoutTemplates.cs:43(GrowthPercentage)<span style='color:green'>+12%</span>ContentIndexSettingsTab.BuildChunkPanelgap:6pxstacksamples/Graph/Data/ACME/Project/Todo/Source/TodoLayoutAreas.cs:117<h1 style="margin: 0; flex: 1; min-width: 200px;">flex: 1is on the fragment's own root precisely because that root IS the direct flex child of the enclosingFluentStack. A wrapper interposes a non-grow block and makes it inertThe last is decisive: a call site that provably depends on the bare-fragment behaviour today. It lives
in
samples/Graph/Data/.../Source/— in-mesh source, compiled at runtime, rendered by no build andno test — so that regression would surface only in a running portal.
Why gate on the declaration, not the resolved value
HasStylingreadsViewModel.Style/ViewModel.Class(the declaration) rather than the boundStyle/Classfields (the resolved values). Either may be aJsonPointerReferencethat resolves aframe or more after the first render; gating on the resolved value would render the fragment bare and
then restructure the DOM around it when the pointer lands. The declaration is known at bind time and
never changes for a given view-model, so the box is stable from frame one. Nothing is lost:
BindDatafeeds those fields from exactly those two properties, so a resolved value can only be non-null when the
declaration was.
"Never silently ignored again"
With the conditional wrapper there is no case where honouring
Styleis impossible — a<div>canalways carry it — so there is nothing to log or throw about. The contract is made explicit in the XML
docs on
HtmlControl(bare fragment by default; setting Style/Class opts into a box) and pinned by thetests below.
Reviewer-relevant, because I got this wrong first and the correction is the second commit.
I initially claimed "not one call site sets Style or Class on an HtmlControl." Then I found ~100
Controls.H2(...).WithStyle("margin: 0")sites and assumed they wereHtmlControls, becauseControls.Titleis one. They are not, and a test I wrote to pin the supposed consequence failedwith
Unable to cast LabelControl to HtmlControl:Label.razorappliesStyle="@Style"in both branches, so those ~100 heading sites never had this bugand are untouched.
HeadingFactories_AreLabelControls_NotHtmlControlsnow pins that distinction, sore-implementing
H1..H6on top ofTitlefails loudly instead of silently widening the blast radius.A balanced-paren scan (multi-line aware) for
Controls.Html/Controls.Title/new HtmlControlfollowed by a
.WithStyle/.WithClasschain, acrosssrc/ samples/ content/ memex/, finds threereal call sites — the true blast radius:
samples/Todo/.../TodoLayoutAreas.cs:1717WithMarginBottom("8px")on a<textarea>fragmentsrc/MeshWeaver.Graph/VersionLayoutArea.cs:153margin: 0 0 16px 0onControls.Title(…, 2)src/MeshWeaver.Graph/VersionLayoutArea.cs:443margin: 0 0 16px 0onControls.Title(…, 2)All three were asking for a style that was being dropped; all three now get it. For the two
Titlesites the style lands on the wrapper, so the declared margin adds to the
<h2>'s own UA margin ratherthan replacing it — strictly better than dropping it, but the author's literal intent needs
Titletohave its own control/view instead of borrowing
HtmlControl. Left as a follow-up: it changes apublic factory's return type, including for in-mesh callers.
Tests — verified by falsification
Seven tests added to
ControlStyleRenderingTest, which renders through the realHtmlRendereragainst a real monolith mesh and asserts on emitted HTML (asserting
control.Styleproves nothing —the value was always set correctly; the markup dropped it).
I reverted
HtmlView.razorto the pre-fix markup and re-ran the first four:markup is unchanged, so they are real guards, not tautologies:
HtmlControl_WithoutStyleOrClass_EmitsTheBareFragment— assertshtml.Should().Be(fragment)exactly, for
<span>inline</span>. This is the backwards-compatibility assertion: an unstyledHtmlControlemits markup byte-identical to today, no wrapper introduced.HtmlControl_MultiRootFragment_StaysMultiRoot—<div>label</div><div>value</div>stays twosiblings, so a parent stack's gaps are not collapsed.
Plus
TitleControl_IsAnHtmlControl_SoItsStyleNowReachesAWrapper,TitleControl_WithoutStyle_IsStillABareHeading, andHeadingFactories_AreLabelControls_NotHtmlControls.With the fix:
Passed! - Failed: 0, Passed: 13, Total: 13.Verification
dotnet build src/MeshWeaver.Layout -c Release -warnaserrordotnet build src/MeshWeaver.Blazor -c Release -warnaserrordotnet build test/MeshWeaver.Hosting.Blazor.Test -c Release -warnaserrorControlStyleRenderingTest(13)DocumentationLinkIntegrityTest(What's New note added)What's New entry added (
Category: Fix).📋 Audit:
Style/Classacross all Blazor control views — information only, NOT fixed hereRequested alongside the fix so the owner can decide whether the third bucket warrants a follow-up.
Nothing below is changed by this PR.
72 control views examined — every
.razorundersrc/MeshWeaver.Blazor/Components/(incl.Monaco/) whose@inheritsresolves toBlazorView<,>or a subclass; code-behinds checked for all.Base-class facts:
BlazorView.BindData(BlazorView.razor.cs:485-497) bindsId/Class/Styleforevery control view.
SkinnedView.cs:40-46repeats the same three binds.FormComponentBase.cs:60-70folds Style+Width+Height into
ComputedStyle, which form views are supposed to emit instead ofStyle.🔴 Bucket 3 — silently drops it (25)
Drops both Style and Class (17)
AppearanceView<div style="max-width: 500px;">(l.6) — literal;@Style/@Classnever appearCatalogViewstyle="@ContainerStyle"(l.5) whereContainerStyleis a literal in the code-behind (l.27)CollaborativeMarkdownViewclass="collab-md-container @ViewModeClass"(l.11) — private computed class, notClassCommentableViewclass="commentable-wrapper"(l.7); rendersViewModel.Areaschildren, so nothing downstream carries the container's own Style eitherDialogViewstyle="@GetDialogStyle()"(l.9) composes only--dialog-width/--dialog-heightfromSizeExportDocumentViewclass="export-document-view" style="max-width: 720px;"(l.16) — literalLayoutAreaDefinitionView<a class="card layout-area-card …">(l.4) — fixed classes, no style attributeMarkdownEditorViewclass="collaborative-editor-container"(l.25); no@Style/@Classin fileMeshNodeCardViewstyle="text-decoration: none; …"(l.12, 18) +<FluentCard Class="mesh-node-card" Style="cursor: pointer; …">(l.60)MeshNodeContentEditorViewclass="mesh-node-content-editor" style="display: flex; …"(l.16) — literalMeshNodePickerViewFormComponentBaseyet root is<div class="mesh-node-picker…">(l.6) with noComputedStyle— drops Style, Class, Width and HeightMeshNodeRoleEditorViewstyle="display: flex; align-items: center; gap: 8px;"(l.15)MeshNodeThumbnailView<FluentCard Class="mesh-node-thumbnail" Style="min-width: 320px; …">(l.4) — both params present but hard-coded, shadowing the bound valuesNavLinkclass="menu-item-link@(IsActive…)"(l.6) /Class="@(IsActive ? "active" : null)"(l.21) —Classoverwritten by active-state token;Stylenever appearsNodeExportViewclass="node-export-view" style="max-width: 800px;"(l.11) — literalNodeImportViewclass="node-import-view" style="max-width: 800px;"(l.9) — literalMonaco/DiffEditorViewstyle="height: @(ViewModel?.Height ?? "500px"); width: 100%;"(l.8) readsViewModel.Heightdirectly;CssClassfixedDrops Class only (Style works) (8)
EditorView<div style="@Style">(l.4); innerclass="form-group"fixed;@Classnever emittedLabelStyle="@Style"(l.13, 26) onFluentLabel;Classnever passed though the param existsLayoutAreaViewclass="layout-area-container @(IsContentLoaded…) …" style="@Style"(l.5) —@Classnot mergedMarkdownView<article class="markdown-body" … style="…; @Style">(l.3) — class list fixedMeshNodeCollectionView<div class="mesh-node-collection" style="@Style">(l.8)NumberFieldView@ComputedStyleapplied correctly (l.8), but inner input usesclass="@appearanceClass"(l.60), a local valueTabsView<FluentTabs … Style="@Style">(l.4-10);FluentTabsacceptsClass, not passedThreadMessageBubbleViewclass="thread-msg-bubble @(IsUser…)" style="@Style"(l.6) —@Classnot merged🟡 Bucket 4 — unclear (1)
NamedAreaViewclass="skeleton-placeholder"l.37,FluentStackwith conditional literal Style l.50) but in the loaded branch emits only<DispatchView ViewModel="@RootControl">(l.72) — a different control applying its own Style. The NamedArea's own Style/Class are never rendered, but whether they are meant to (there is no steady-state element to carry them) is a design call I could not settle from the code.🟢 Bucket 1 — applies Style and Class (39)
BadgeView,BodyContentView,ButtonView,CardView,Checkbox,Combobox,ComparisonBarsView,DataGridView,DateTimeView,DocumentSourceView,FooterView,HeaderView,HighlightView,HtmlView(this PR — conditional wrapper),IconView,ItemTemplate,KpiStripView,LayoutGridItemView,LayoutGridView,LayoutStackView,LayoutView,Listbox,MainView,MenuItemView,NavGroup,NavMenuView,ProgressView,RadioGroupView,SearchView,SelectView*,SplitterPane,SplitterView,Switch,TabView,TextAreaView,TextFieldView,ToolbarView,TowerView,VideoView.Mechanism is
Class="@Class"+Style="@Style"on a Fluent component, orclass="… @Class" style="@Style"on a plain element; the form inputs (Checkbox,Combobox,Listbox,RadioGroupView,SearchView,Switch,TextFieldView,NumberFieldView) correctly useComputedStyle.* Three caveats worth a look, on a different axis (Style/Class are fine):
SelectViewandDateTimeViewareFormComponentBaseviews that passStyleinstead ofComputedStyle→ the boundWidth/Heightare dropped.MenuItemViewre-bindsDataBind(Skin.Style, x => x.Style)(l.127) after the base boundViewModel.Style, so the skin wins and a null skin style nulls the field. Coherent withMenuItemControl.WithStyle(object)routing into the skin, but the inheritedWithStyle(Func<StyleBuilder,…>)overload writes the control's Style and is clobbered.⚪ Bucket 2 — deliberately N/A (7)
SpacerViewFluentSpacerexposes no Style/Class parameter (the case the existing test doc-comment already calls out)ContainerViewforeachover children rendering oneDispatchVieweach (l.3-11).DispatchViewEditFormViewPropertyView<dt>/<dd>chrome plus<DispatchView ViewModel="@ViewModel">(l.13) — same control, terminal view applies itRedirectViewOnInitializedAsyncnavigatesSlideShowViewSearchBoxView,MeshSearchViewandMonaco/CodeEditorVieware registered as control views inBlazorViewRegistry.cs(l.144, 147, 134) but derive from plainComponentBaseand take[Parameter] ViewModel/Stream/Areaby hand.Style/Classare therefore not merely dropped inmarkup — they are never bound. Strictly outside the bucket definitions, but the same user-visible
defect.
Counts
Whether bucket 3 warrants a systemic follow-up is the owner's call — several of the 17 are
single-purpose views whose box is genuinely fixed by design, but
MeshNodePickerView(dropsWidth/Height too) and the 8 Class-only drops look like plain omissions.
🤖 Generated with Claude Code