Skip to content

A dropped numeric bound is retained because the Fluent inputs are bound by attribute splat #348

Description

@phmatray

Problem / motivation

The Fluent numeric components pass Min, Max and Step to FluentNumberInput by splatting a
dictionary:

private Dictionary<string, object> ExtraAttributes { get; } = [];

private void AddIfConfigured(string key)
{
    if (GetAttribute<TValue?>(key) is { } value)
    {
        ExtraAttributes[key] = value;
    }
}
<FluentNumberInput TValue="TValue"  @attributes="ExtraAttributes" />

A key is only ever added, so a field that configures no bound contributes nothing — and that is
the problem
. Blazor retains a component parameter that a later render stops supplying. Omitting
the key is therefore not the same as unsetting it: when the same component instance is handed a
different field, FluentNumberInput.Min keeps the previous field's value and the input goes on
constraining input the developer never asked it to.

#335 cleared the dictionary on every configuration reload, which fixed the accumulation half —
ExtraAttributes is now correct. It cannot fix the retention half, because there is no value to
supply that means "unset": the only way to express it through a splat is to hand Fluent its own
default (int.MinValue, decimal.MinValue, …), which FormCraft would have to know per TValue.

FieldConfigurationRefreshTests.NumericField_Should_Rebind_Its_Min_When_The_Configuration_Is_Swapped
covers the case that does work — swapping one bound for another — and its remarks record why the
drop case is not covered.

How you hit it: swap FormCraftComponent.Configuration on a live form (a wizard step, a mode
toggle) between a numeric field that declares a bound and one that does not.

Proposed solution

Bind the bounds as real, nullable parameters instead of splatting a dictionary, so "not configured"
is expressible as null and Blazor is always supplied a value.

<FluentNumberInput TValue="TValue"  Min="@Min" Max="@Max" Step="@Step" />

with Min/Max/Step as TValue? properties assigned unconditionally in
OnFieldConfigurationChanged() — the reload-not-patch rule the hook already documents, applied to the
binding rather than to a dictionary that feeds it.

Check what FluentNumberInput actually declares first: if its Min is a non-nullable TValue with a
default, FormCraft has to supply that default explicitly, and the fix is to name it in one place
rather than to leave the parameter unsupplied.

Alternatives considered

Keep the splat and add the defaults to the dictionary. Smallest change, and it puts knowledge of
Fluent's per-type defaults inside FormCraft where it will rot the first time Fluent changes one.

Accept it and document the limitation. The failure needs a configuration swap between a bounded
and an unbounded numeric field, which is narrow. But it is silent and it constrains user input, and
"narrow and silent" is the profile of every bug this adapter pair has already had to fix twice.

Do the same for the MudBlazor adapter's equivalent. MudTextField.Mask behaves the same way
(noted under #308): FormCraft sets its own Mask to null and MudBlazor retains the previous
PatternMask object — though there the rendering correctly stops masking, so it is a stale
reference rather than stale behaviour. Worth confirming while here; not assumed.

Area

FormCraft.ForFluentUIFields/NumericField/FluentUINumericFieldComponent.razor(.cs) and
FluentUINullableNumericFieldComponent.razor(.cs).

Related: #335, #336, #298, #308

🧠 Brainstorm

Problem / context

#335 moved the configuration-refresh hook into core and swept both adapters with it. The sweep found
three "patch-not-reload" shapes; two were fully fixable (?? Options in the selects, the accumulating
ExtraAttributes dictionary) and this one is only half-fixable from inside the hook, because the
residue is in how the value reaches Fluent rather than in how FormCraft computes it.

The distinction matters for anyone picking this up: the dictionary is already correct. Re-reading
AddIfConfigured looking for the bug will find nothing. The bug is that @attributes cannot express
absence.

Approaches

A. Nullable parameters bound explicitly. Min="@Min" with TValue? Min assigned every reload.
Blazor always supplies the parameter, so null is a real value and absence round-trips. Costs three
properties per numeric component and removes the dictionary indirection — the components get simpler.

B. Splat, but always populate every key. Keeps the dictionary and writes Fluent's default when the
attribute is absent. Requires FormCraft to know Fluent's per-TValue defaults, which is knowledge in
the wrong package.

C. Leave it, document it. Free. Leaves a silent input-constraining bug behind a narrow trigger.

Recommendation

A. It is the only option that makes absence representable, and it shrinks the component rather
than growing it. The one thing to verify before committing to it is FluentNumberInput's own
signature — if Min is non-nullable there, A degenerates into B and the choice needs revisiting with
that fact in hand rather than around it.

Note the aria-required entry currently shares ExtraAttributes; it is unconditional, so it can stay
in a splat or move to a parameter with the rest — either is fine, but do not leave a dictionary
holding one key just to avoid touching it.

📋 Spec

Goal

A numeric field that declares no bound renders unbounded, even on a component instance that previously
rendered a field which did.

Scope

  • Min/Max/Step become explicitly-bound parameters on both Fluent numeric components.
  • They are assigned on every configuration reload, including back to "unset".
  • A test covering the drop case, which today's suite explicitly does not cover.

Non-goals

  • The MudBlazor numeric components, unless the same shape is found there (it binds Min/Max directly
    already — confirm rather than assume).
  • Any change to how bounds are configured: .WithAttribute("Min", …) keeps working unchanged.

Design

flowchart LR
    A[field declares Min] --> B[OnFieldConfigurationChanged]
    C[field declares no Min] --> B
    B --> D["Min = GetAttribute&lt;TValue?&gt;&#40;'Min'&#41;"]
    D --> E["&lt;FluentNumberInput Min=@Min /&gt;"]
    E --> F[Blazor always supplies the parameter,<br/>so null round-trips as unset]
Loading

Key files

  • modify FormCraft.ForFluentUI/Fields/NumericField/FluentUINumericFieldComponent.razor + .razor.cs.
  • modify FormCraft.ForFluentUI/Fields/NumericField/FluentUINullableNumericFieldComponent.razor + .razor.cs.
  • tests: FormCraft.ForFluentUI.UnitTests/Fields/FieldConfigurationRefreshTests.cs,
    FluentUINumericFieldComponentTests.cs.

Validation rules

  • Swapping a bounded field for an unbounded one leaves the input unbounded.
  • Swapping one bound for another rebinds it (already covered; must stay green).
  • A field configuring no bounds at all renders exactly as it does today.
  • aria-required still reaches the input.

Edge cases

  • FluentNumberInput.Min may be non-nullable. Establish this first; it decides whether "unset" is
    null or an explicit default, and therefore which approach is even available.
  • TValue is unconstrained across the two components (struct on one, nullable on the other), so
    the "unset" value differs per component — do not assume one spelling covers both.
  • The existing FluentUINumericFieldComponentTests assertions on Min/Max/Step must keep passing
    untouched; they pin the configured path.

Assumptions

🛠️ Implementation plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: a dropped numeric bound is actually dropped, not retained from the previous field.
Architecture: bind Min/Max/Step as real parameters so absence is representable; the reload assigns them unconditionally.
Tech stack: .NET 8/10, Blazor, Fluent UI Blazor v5, xUnit + Shouldly + bUnit.

Global Constraints

  • Keep FormCraft (core) UI-agnostic — this is entirely a FormCraft.ForFluentUI change.
  • Reload, not patch — every cached property is assigned on every OnFieldConfigurationChanged() call, including back to unset.
  • No change to the configured path — the existing numeric tests must pass untouched.
  • dotnet test --filter is inert here (MTP0001) — run dotnet test -c Release.
  • dotnet format is a CI gate since The .editorconfig's style rules are enforced nowhere, so 574 violations have accumulated #301dotnet format FormCraft.sln --verify-no-changes must exit clean before the PR is ready.
  • Build in Release: TreatWarningsAsErrors=true; do not relax it.
  • Commit identity: Philippe Matray <phmatray@gmail.com>. PRs target dev.

Task 1: Establish what Fluent actually declares, and pin the drop case

Files: test FormCraft.ForFluentUI.UnitTests/Fields/FieldConfigurationRefreshTests.cs.

Interfaces: none yet — this task decides whether "unset" is null or an explicit default.

  • Step 1: Write a characterisation test recording FluentNumberInput<int>'s Min/Max/Step types and their values on a field that configures none.
  • Step 2: Write a failing test: render a numeric field with Min, swap Configuration for one declaring no bound, assert the input is unbounded.
  • Step 3: Run dotnet test -c Release → the characterisation passes, the drop test FAILS.
  • Step 4: Record in the test's remarks which spelling of "unset" the characterisation proved is available.
  • Step 5: Commit: test(fluentui): pin a dropped numeric bound

Task 2: Bind the bounds explicitly

Files: modify FormCraft.ForFluentUI/Fields/NumericField/FluentUINumericFieldComponent.razor + .razor.cs, and the nullable variant's pair.

Interfaces: TValue? Min, TValue? Max, TValue? Step assigned in OnFieldConfigurationChanged(); ExtraAttributes keeps only what genuinely belongs in a splat.

  • Step 1: Add the properties and assign all three unconditionally in the hook, replacing AddIfConfigured.
  • Step 2: Bind them on the FluentNumberInput in both razor files.
  • Step 3: Run dotnet test -c Release → PASS, including the existing FluentUINumericFieldComponentTests bound assertions.
  • Step 4: Verify the new test is load-bearing: temporarily restore the splat, confirm the drop test fails, restore.
  • Step 5: Run dotnet format FormCraft.sln --verify-no-changes → clean.
  • Step 6: Commit: fix(fluentui): bind numeric bounds explicitly so a dropped bound is dropped

Task 3: Confirm or clear the MudBlazor equivalent

Files: test FormCraft.ForMudBlazor.UnitTests/Fields/FieldConfigurationRefreshTests.cs.

Interfaces: none — this task establishes whether the same defect exists next door.

  • Step 1: Write a test swapping a MudBlazor numeric field's Format/Min for a field declaring none, asserting the rendered input drops it.
  • Step 2: Run dotnet test -c Release → record whether it passes (MudBlazor binds directly, so it should) or fails.
  • Step 3: If it fails, fix it the same way; if it passes, keep the test as the pin that says the two adapters agree.
  • Step 4: Commit: test(mudblazor): pin that a dropped numeric bound is dropped

Task 4: Document it

Files: modify README.md.

Metadata

Metadata

Assignees

No one assigned

    Labels

    priority:lowNice to havestatus:triagedClassified and ready for analysis/worktype:bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions