Skip to content

25 of 72 control views silently drop Style/Class — the API lets you set what the view ignores #1297

Closed

Description

@rbuergi

Style and Class are public API on UiControl (src/MeshWeaver.Layout/UiControl.cs:16,52), and WithStyle(...) is used widely — but 25 of 72 control views never apply them. An author sets a style, the view drops it, and nothing reports anything.

Why this is filed rather than fixed

PR #1288 fixed HtmlView, the worst instance: Controls.Html(x).WithStyle("width:100%") was silently a no-op, which is why three Reinsurance charts rendered at ~6% of column width. The audit below came out of that work and is deliberately not included in it — a 25-view sweep needs its own review.

The audit (72 control views under src/MeshWeaver.Blazor/)

Bucket Count
🔴 Silently drops — 17 drop both, 8 drop Class only 25
🟡 Unclear (NamedAreaView) 1
🟢 Applies both 39
⚪ Deliberately N/A (no meaningful box) 7

Plus three outside the buckets: SearchBoxView, MeshSearchView, Monaco/CodeEditorView are registered as control views in BlazorViewRegistry but derive from plain ComponentBase, so Style/Class are never bound at all.

Standouts:

  • The 8 Class-only drops look like plain omissions rather than design — they bind Style and forget Class.
  • MeshNodePickerView is a FormComponentBase and drops Width/Height too.

What "fixing" should and should not mean

🚨 Not a blanket wrapper. #1288 established why, with a case that would have shipped undetected: samples/Graph/Data/ACME/Project/Todo/Source/TodoLayoutAreas.cs:117 sets flex: 1 on its fragment's own root because that root is the direct flex child. A wrapper makes it inert — and that file is in-mesh source, so no build and no test would catch it. Each of the 25 needs its own judgement about where the style belongs.

Also from #1288, worth reusing: gate on the declaration (ViewModel.Style), not the resolved value. Either can be a JsonPointerReference that lands a frame later, so gating on the value renders bare and then restructures the DOM mid-flight.

The general defect, which outlives any individual view

The API lets an author set something the view may silently ignore, and there is no signal — no log, no failure, no test. That is the same shape as the other silent-success traps collected today (a gate passing on a stale tag set, dotnet test --no-restore on an unbuilt project exiting 0). Worth considering whether a view that cannot honour a declared Style should say so, so the 26th instance reports itself instead of being found by someone squinting at a 6%-wide chart.

Full per-view classification is in PR #1288's body.

Activity

  1. rbuergi commented on Aug 12, 2026

    @rbuergi
    ContributorAuthor

    Swept, split into three stacked PRs so each stays reviewable. All 25 views fixed, plus the
    "unclear" one resolved and the three out-of-bucket ones tracked separately.

    PR Scope Views
    #1323 the Class-only drops 8
    #1327 both dropped, single literal root 10
    #1332 both dropped, the box was not the obvious root 7

    Merge order is #1323 → #1327 → #1332 — each is based on the previous one because they share the
    ClassSuffix / StyleSuffix helpers on BlazorView. GitHub re-targets each to main automatically as
    its base merges.

    No blanket wrapper — and no new element anywhere

    Not one of the 25 gained a wrapper. Every fix lands on an element the view already renders, chosen
    per view; in the two cases where the view has no element of its own the declaration is forwarded to
    the control it delegates to (MeshNodeCardView's embedded area), the shape PropertyView /
    EditFormView already use. So the TodoLayoutAreas.cs:117 class of regression — flex: 1 on a
    fragment's own root, in in-mesh source no build type-checks — cannot arise from this sweep.

    Two rules came out of it that were not in the original audit:

    • The author's Style goes LAST in an attribute that already has literals, so a declared max-width
      overrides the view's default. Dropping the declaration was the bug; letting the default win would be
      the same bug with extra steps. Asserted as ordering, not presence.
    • Gating on the declaration (ViewModel.Style) is specific to the wrapper case and was deliberately
      not copied here: no element is created or removed, so a late-resolving JsonPointerReference just
      updates an attribute — it cannot restructure the DOM mid-flight. Applying fix: HtmlView honours Style/Class instead of silently dropping them #1288's rule mechanically
      would have been cargo-culting.

    Backwards compatibility

    Both helpers return the empty string when nothing was declared, so an unstyled control's markup is
    byte-identical — the guards assert the exact attribute (class="markdown-body"), not Contain, so
    a trailing space or an empty class="" fails. One deliberate exception, called out in #1327:
    CollaborativeMarkdownView previously emitted a trailing space in its class attribute in the default
    view mode; the tokens are now joined. A class attribute is a token list, so that space never matched
    anything.

    Every positive test was verified by falsification — reverting the views makes exactly those tests
    fail (5, then 9, then 5), while the backwards-compatibility guards pass with and without the fix.
    ControlStyleRenderingTest goes 13 → 44 tests, all through the real HtmlRenderer.

    The specific questions in this issue

    • MeshNodePickerView's Width/Height — the same fix, and one line. FormComponentBase
      already folds Style+Width+Height into ComputedStyle; this view just never emitted it. Switching its
      root to style="@ComputedStyle" recovers all three. (fix: the seven views where the box was not the obvious root (#1297, part 3) #1332)
    • NamedAreaView — resolved as deliberately N/A (bucket ⚪), with the reasoning recorded in the
      view itself. A NamedAreaControl is a reference to an area, not a box; the loaded branch renders
      only <DispatchView> on the resolved control, which applies its own Style/Class. And the blast radius
      makes a wrapper actively wrong rather than merely unnecessary: every layout area, tab body and dialog
      body reaches the DOM through this view. The only markup it owns is the not-yet-loaded skeleton/spinner
      — styling that would apply the declaration to a spinner and then drop it when content lands, the exact
      mid-flight restructure the declaration-gating rule exists to prevent. If a call site ever needs it, the
      fix is to forward onto RootControl. (fix: the seven views where the box was not the obvious root (#1297, part 3) #1332)
    • SearchBoxView / MeshSearchView / Monaco/CodeEditorView — a different defect, filed as
      Three registered control views never bind Style/Class at all — they derive from ComponentBase #1333.
      Nothing is dropped in markup because nothing is bound: they derive from plain ComponentBase,
      so BlazorView.BindData never runs. Fixing it is a view-base/lifecycle question, not an attribute
      edit, and folding it in here would have turned a markup sweep into a rewrite. Three registered control views never bind Style/Class at all — they derive from ComponentBase #1333 also proposes the
      durable guard: constrain StandardView<,> to a BlazorView<,>-derived component so a fourth instance
      cannot be registered silently.

    Not addressed: the general "silently ignored" defect

    The closing paragraph of this issue asks whether a view that cannot honour a declared Style should say
    so. Nothing here does that — after the sweep there is exactly one view that genuinely cannot
    (SpacerView: FluentSpacer exposes no Style/Class parameter, and wrapping it would change its flex
    behaviour), so a runtime signal would have one true positive and no obvious home. The mechanism that
    would actually prevent the 26th instance is a compile-time one — the StandardView<,> constraint in
    #1333, and the render tests, which now cover 25 views and fail loudly when a view stops emitting.
    Flagging it rather than deciding it: that is a scope call for you, not for the sweep.

  2. rbuergi commented on Aug 12, 2026

    @rbuergi
    ContributorAuthor

    One more thing the sweep did not touch, recorded here because it would otherwise only exist in #1288's (now merged) audit body.

    The audit's "three caveats on a different axis" are still open, and two of them are the same defect I fixed in MeshNodePickerView as part of the 25:

    • SelectView and DateTimeView are FormComponentBase views that pass Style where every other form view passes ComputedStyle — so their Width/Height are dropped. Style and Class themselves are fine, which is why they sit outside this issue's buckets. One line each; the reason I did not fold them in is that they are not Style/Class drops, and neither is renderable in ControlStyleRenderingTest (form views need a live pointer stream), so they would have ridden along untested inside a PR whose whole claim is falsification-verified coverage.
    • MenuItemView re-binds DataBind(Skin.Style, x => x.Style) after the base bound ViewModel.Style, so the skin wins and a null skin style nulls the field. Coherent with MenuItemControl.WithStyle(object) routing into the skin, but the inherited WithStyle(Func<StyleBuilder,…>) overload writes the control's Style and is then clobbered. That one is a genuine design question (which of the two overloads is authoritative), not an omission.

    Happy to take either as a follow-up — flagging rather than deciding.

  3. added 3 commits that reference this issue on Aug 12, 2026
  4. rbuergi commented on Aug 12, 2026

    @rbuergi
    ContributorAuthor

    All 25 views fixed across three merged PRs — #1323 (the 8 Class-only drops), #1327 (10 views with a literal root), #1332 (7 where the box was not the obvious root).

    Nothing gained a wrapper. Every fix lands on an element the view already renders, or forwards the declaration to the control it delegates to. That was the constraint from #1288: a blanket wrapper breaks TodoLayoutAreas.cs:117, which sets flex: 1 on its fragment's own root because that root is the direct flex child — and being in-mesh source, no build or test would have caught it.

    Two rules emerged that were not in the original audit and are now pinned by tests:

    • The author's Style goes last, so a declared max-width overrides the view's default. Dropping it was the bug; letting the default silently win is the same bug with extra steps. Asserted as ordering, not presence.
    • fix: HtmlView honours Style/Class instead of silently dropping them #1288's "gate on the declaration" is wrapper-specific and deliberately not copied here — no element is created, so a late-resolving pointer just updates an attribute.

    ControlStyleRenderingTest went 13 → 44 tests, all through the real HtmlRenderer. Every positive test was falsified by reverting its view; the backwards-compat guards assert exact attributes rather than Contain, and pass with and without the fix. One guard was missing a space and therefore could never fail — caught and proven failing by temporarily double-applying the style.

    Left deliberately, each with reasoning recorded:

    MeshNodePickerView turned out to be one line: FormComponentBase.ComputedStyle already folds Style+Width+Height and the view simply never emitted it — so emitting it recovers all three.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions