You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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.ForFluentUI — Fields/NumericField/FluentUINumericFieldComponent.razor(.cs) and FluentUINullableNumericFieldComponent.razor(.cs).
#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<TValue?>('Min')"]
D --> E["<FluentNumberInput Min=@Min />"]
E --> F[Blazor always supplies the parameter,<br/>so null round-trips as unset]
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.
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.
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
Problem / motivation
The Fluent numeric components pass
Min,MaxandSteptoFluentNumberInputby splatting adictionary:
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.Minkeeps the previous field's value and the input goes onconstraining input the developer never asked it to.
#335 cleared the dictionary on every configuration reload, which fixed the accumulation half —
ExtraAttributesis now correct. It cannot fix the retention half, because there is no value tosupply 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 perTValue.FieldConfigurationRefreshTests.NumericField_Should_Rebind_Its_Min_When_The_Configuration_Is_Swappedcovers 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.Configurationon a live form (a wizard step, a modetoggle) 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
nulland Blazor is always supplied a value.with
Min/Max/StepasTValue?properties assigned unconditionally inOnFieldConfigurationChanged()— the reload-not-patch rule the hook already documents, applied to thebinding rather than to a dictionary that feeds it.
Check what
FluentNumberInputactually declares first: if itsMinis a non-nullableTValuewith adefault, 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.Maskbehaves the same way(noted under #308): FormCraft sets its own
Masktonulland MudBlazor retains the previousPatternMaskobject — though there the rendering correctly stops masking, so it is a stalereference rather than stale behaviour. Worth confirming while here; not assumed.
Area
FormCraft.ForFluentUI—Fields/NumericField/FluentUINumericFieldComponent.razor(.cs)andFluentUINullableNumericFieldComponent.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 (
?? Optionsin the selects, the accumulatingExtraAttributesdictionary) and this one is only half-fixable from inside the hook, because theresidue 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
AddIfConfiguredlooking for the bug will find nothing. The bug is that@attributescannot expressabsence.
Approaches
A. Nullable parameters bound explicitly.
Min="@Min"withTValue? Minassigned every reload.Blazor always supplies the parameter, so
nullis a real value and absence round-trips. Costs threeproperties 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-
TValuedefaults, which is knowledge inthe 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 ownsignature — if
Minis non-nullable there, A degenerates into B and the choice needs revisiting withthat fact in hand rather than around it.
Note the
aria-requiredentry currently sharesExtraAttributes; it is unconditional, so it can stayin 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/Stepbecome explicitly-bound parameters on both Fluent numeric components.Non-goals
Min/Maxdirectlyalready — confirm rather than assume).
.WithAttribute("Min", …)keeps working unchanged.Design
flowchart LR A[field declares Min] --> B[OnFieldConfigurationChanged] C[field declares no Min] --> B B --> D["Min = GetAttribute<TValue?>('Min')"] D --> E["<FluentNumberInput Min=@Min />"] E --> F[Blazor always supplies the parameter,<br/>so null round-trips as unset]Key files
FormCraft.ForFluentUI/Fields/NumericField/FluentUINumericFieldComponent.razor+.razor.cs.FormCraft.ForFluentUI/Fields/NumericField/FluentUINullableNumericFieldComponent.razor+.razor.cs.FormCraft.ForFluentUI.UnitTests/Fields/FieldConfigurationRefreshTests.cs,FluentUINumericFieldComponentTests.cs.Validation rules
aria-requiredstill reaches the input.Edge cases
FluentNumberInput.Minmay be non-nullable. Establish this first; it decides whether "unset" isnullor an explicit default, and therefore which approach is even available.TValueis unconstrained across the two components (structon one, nullable on the other), sothe "unset" value differs per component — do not assume one spelling covers both.
FluentUINumericFieldComponentTestsassertions onMin/Max/Stepmust keep passinguntouched; they pin the configured path.
Assumptions
this repo during fix(fluentui): re-read a field's configuration when the rendered field changes (#335) #336, and the reason this issue exists — re-verify with a focused test rather than
taking it on faith, since the whole design follows from it.
🛠️ Implementation plan
Goal: a dropped numeric bound is actually dropped, not retained from the previous field.
Architecture: bind
Min/Max/Stepas 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
FormCraft(core) UI-agnostic — this is entirely aFormCraft.ForFluentUIchange.OnFieldConfigurationChanged()call, including back to unset.dotnet test --filteris inert here (MTP0001) — rundotnet test -c Release.dotnet formatis a CI gate since The .editorconfig's style rules are enforced nowhere, so 574 violations have accumulated #301 —dotnet format FormCraft.sln --verify-no-changesmust exit clean before the PR is ready.TreatWarningsAsErrors=true; do not relax it.Philippe Matray <phmatray@gmail.com>. PRs targetdev.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
nullor an explicit default.FluentNumberInput<int>'sMin/Max/Steptypes and their values on a field that configures none.Min, swapConfigurationfor one declaring no bound, assert the input is unbounded.dotnet test -c Release→ the characterisation passes, the drop test FAILS.test(fluentui): pin a dropped numeric boundTask 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? Stepassigned inOnFieldConfigurationChanged();ExtraAttributeskeeps only what genuinely belongs in a splat.AddIfConfigured.FluentNumberInputin both razor files.dotnet test -c Release→ PASS, including the existingFluentUINumericFieldComponentTestsbound assertions.dotnet format FormCraft.sln --verify-no-changes→ clean.fix(fluentui): bind numeric bounds explicitly so a dropped bound is droppedTask 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.
Format/Minfor a field declaring none, asserting the rendered input drops it.dotnet test -c Release→ record whether it passes (MudBlazor binds directly, so it should) or fails.test(mudblazor): pin that a dropped numeric bound is droppedTask 4: Document it
Files: modify
README.md.dotnet build -c Releaseanddotnet test -c Release→ both green.docs: note that dropped numeric bounds revert