Skip to content

Upload components read their constraints ad-hoc, so AsFileUpload is silently ignored on the single-file MudBlazor path #340

Description

@phmatray

Problem / motivation

.AsFileUpload(acceptedFileTypes: [".pdf"], maxFileSize: 5 * 1024 * 1024) — the public, documented way
to constrain a file upload — is silently ignored on the single-file MudBlazor path. No accept
filter, no size cap, no exception, no diagnostic. A 500 MB .exe is accepted by a field the developer
constrained to 5 MB of PDF.

Verified on dev (01a6580), reading the two halves against each other:

FormCraft/Forms/Extensions/FieldBuilderExtensions.cs:299AsFileUpload writes exactly one
attribute, and nothing else:

var config = new FileUploadConfiguration { AcceptedFileTypes =, MaxFileSize =, MaxFiles = 1,};

// No renderer override: the field type dispatches to the UI framework's
// file upload component, which reads this configuration attribute.
builder.WithAttribute("FileUploadConfiguration", config);

FormCraft.ForMudBlazor/Fields/FileUploadField/MudBlazorFileUploadFieldComponent.razor.cs:38-44
OnInitialized reads only raw keys, and never FileUploadConfiguration:

Accept        = GetAttribute<string>("Accept");
MaxFileSize   = GetAttribute<long?>("MaxFileSize");
MaxFiles      = GetAttribute<int?>("MaxFiles");

Every one of those comes back null. The comment in AsFileUpload is therefore false for this
component: it asserts the component reads the configuration attribute, and it does not.

The sibling gets it right — MudBlazorMultipleFileUploadComponent.razor.cs:36-37 does
GetAttribute<FileUploadConfiguration>("FileUploadConfiguration") — so the two MudBlazor upload
components disagree about where constraints come from
, and only one of them matches the builder.

Reported, same root, needs confirming: FormCraft.ForFluentUI/Fields/FileUploadField/FluentUIFileUploadComponentBase.cs
(~line 111) resolves its fallback as GetAttribute("MaxFileSize", GetAttribute("MaximumFileSize", 10L * 1024 * 1024)),
which infers T = long. FieldComponentBase.GetAttribute returns the stored value only when
value is T, so a .WithAttribute("MaxFileSize", 5_000_000) — which boxes an int — fails is long
and silently reverts to the 10 MB default.

Root cause. There is no single path by which an upload component resolves its constraints. Four
components each pick their own keys and their own CLR types, so each drops a different subset,
silently. The repository already treats this failure mode as an architecture invariant — see
.claude/skills/repo-profile.mdArchitecture grain, ⛔ There is ONE render path, written after
#146/#177/#184/#190 each arrived as a separate bug report for one capability implemented twice. This is
the same shape, in the upload components.

Why it deserves priority:medium. A dropped maxFileSize/acceptedFileTypes is security-adjacent:
the developer wrote a constraint, the UI enforces nothing, and nothing anywhere reports the gap.

Proposed solution

Give upload constraints one resolution path, used by every upload component:

  1. Resolve FileUploadConfiguration first, falling back to the raw per-key attributes for
    hand-written .WithAttribute(...) callers (that fallback is why the raw keys exist).
  2. Make the fallback type-tolerant for numerics, so int and long both bind — the Fluent
    MaxFileSize bug is exactly this coercion missing.
  3. Have all four components (MudBlazor single + multiple, Fluent single + multiple) consume that one
    path, so a new constraint is added once rather than four times.
  4. Cover it with a parity test asserting the same .AsFileUpload(...) configuration reaches every
    upload component — the drift is what the guard has to catch, not any single component's reading.

Alternatives considered

  • Just add FileUploadConfiguration to the single-file MudBlazor component. One line, fixes the
    worst symptom today. Rejected as the whole fix: it leaves four independent readers and the Fluent
    int/long bug intact, so the next constraint drifts again — this issue exists precisely because
    the per-component approach already failed once.
  • Make AsFileUpload also write the raw keys. Tempting (no component changes at all). Rejected:
    it doubles every attribute at the source, so the two spellings can disagree, and any consumer
    reading only one of them is still wrong — it moves the drift rather than removing it.
  • Drop the raw-key fallback and require FileUploadConfiguration. Cleanest model. Rejected as a
    breaking change to hand-written .WithAttribute("Accept", …) callers, which is out of proportion to
    a bug fix; the fallback can stay as long as one path owns it.

Area

UI adapters — file upload field components (FormCraft.ForMudBlazor, FormCraft.ForFluentUI) and
FieldBuilderExtensions.AsFileUpload


Follow-up from #328. Related: #319, #262, #326

🧠 Brainstorm

Problem / context

FormCraft's core is UI-framework-agnostic and dispatches every field through IFieldRendererService to
a per-framework component. Constraints travel as attributes on the field configuration, which makes
the contract between builder and component implicit: the builder writes a key, the component reads a
key, and nothing checks the two agree. When they disagree the failure is total and silent — the
constraint simply does not exist at runtime.

That is what happened here: AsFileUpload was changed (or written) to publish a single
FileUploadConfiguration object, the multiple-file component was updated to read it, and the
single-file component was not. Its code still reads the older raw keys, so it sees nothing.

Approaches

A. Patch the one component. Add a FileUploadConfiguration read to
MudBlazorFileUploadFieldComponent. Pros: smallest possible diff, fixes today's worst symptom.
Cons: three other readers remain, each free to drift; the Fluent int/long coercion bug is
untouched; nothing prevents the fifth upload component from repeating it.

B. One shared resolution path + a parity test. Extract the resolve-config-then-fall-back-to-raw-keys
logic once, with numeric coercion, and have all four components use it. Pros: removes the class of
bug rather than an instance, and matches the invariant the profile already states for the render path;
the parity test makes a future divergence fail in CI. Cons: touches both adapters, so it is a larger
review than A.

C. Change the builder to write both spellings. Pros: no component changes. Cons: two sources of
truth that can disagree; a component reading only the raw keys still misses anything the config-only
path adds later. Moves the problem.

Recommendation

B. A is what the repo already did for presentation attributes, one at a time, across #146, #177,
#184 and #190 — the profile's Architecture grain records that history and the conclusion drawn from
it. The same conclusion applies here: the unit of work is the shared path, not the symptom. The parity
test is what makes it stick, because the failure is invisible at runtime.

📋 Spec

Goal

A constraint written with .AsFileUpload(...) (or a raw .WithAttribute(...)) reaches every upload
component identically, and a future divergence fails a test rather than shipping silently.

Scope

  • One shared constraint-resolution helper for upload components, honouring FileUploadConfiguration
    first and raw per-key attributes as fallback, with int/long tolerance for size values.
  • MudBlazorFileUploadFieldComponent, MudBlazorMultipleFileUploadComponent,
    FluentUIFileUploadComponentBase (and its two components) consume it.
  • A parity test asserting one .AsFileUpload(...) configuration produces the same resolved
    constraints on every upload component.

Non-goals

  • Changing the AsFileUpload public signature, or removing the raw-key fallback (breaking).
  • Enforcing the constraints in new places (e.g. server-side validation) — this is about the configured
    values reaching the component, not about what the component then does with them.
  • The unrelated upload defects tracked in Clearing a multiple-file upload stores null and notifies twice #319.

Behaviour

flowchart TD
    A[".AsFileUpload(types, maxSize)"] --> B["WithAttribute('FileUploadConfiguration', config)"]
    B --> C{"component reads what?"}
    C -->|"MudBlazor multiple: FileUploadConfiguration"| D["constraints applied ✅"]
    C -->|"MudBlazor single: raw keys only"| E["all null → NO limits ❌"]
    C -->|"Fluent: raw key typed long"| F["int attribute dropped → 10MB default ❌"]
    G["one shared resolver:<br/>config → raw keys → coerce numerics"] --> D
Loading

Key files

  • FormCraft/Forms/Extensions/FieldBuilderExtensions.csAsFileUpload (line ~299); its comment
    about the component reading the attribute must become true, or be corrected.
  • FormCraft.ForMudBlazor/Fields/FileUploadField/MudBlazorFileUploadFieldComponent.razor.cs (~38-44).
  • FormCraft.ForMudBlazor/Fields/FileUploadField/MudBlazorMultipleFileUploadComponent.razor.cs (~36).
  • FormCraft.ForMudBlazor/Fields/FileUploadField/MudBlazorFileUploadComponentBase.cs — the existing
    shared base for upload components; the natural home for the resolver.
  • FormCraft.ForFluentUI/Fields/FileUploadField/FluentUIFileUploadComponentBase.cs (~111).

Validation rules

  • .AsFileUpload(acceptedFileTypes: [".pdf"], maxFileSize: N) yields a non-null accept filter and
    MaxFileSize == N on every upload component.
  • .WithAttribute("MaxFileSize", 5_000_000) (an int) binds, and does not fall through to a default.
  • A raw-key-only configuration still binds (the fallback is preserved).

Edge cases

  • int vs long is the coercion that must be handled; a boxed int fails is long.
  • MaxFiles = 1 is set by AsFileUpload for the single-file path — resolving the config must not
    let a multiple-file component inherit a stray MaxFiles = 1.
  • Assert the resolved values the component ends up with, not merely that some attribute exists;
    the whole defect is a value silently not arriving.

Assumptions

  • The raw per-key attributes remain supported for hand-written callers. If the owner would rather make
    FileUploadConfiguration the only spelling, that is a deliberate breaking change and a different issue.

🛠️ 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: one constraint-resolution path for upload components, so a configured limit cannot silently fail to arrive.

Architecture: UI adapters only. Core's AsFileUpload keeps its signature; the fix is in how components resolve what it writes.

Tech stack: .NET 10 SDK pinned by global.json (10.0.302), Blazor, MudBlazor + Fluent UI adapters, xUnit + Shouldly + bUnit.

Global constraints:

  • Base branch dev; commit as Philippe Matray <phmatray@gmail.com>; conventional commits.
  • FormCraft core stays UI-framework-agnostic — no MudBlazor/Fluent type may leak into it.
  • Do not add a second render path — the resolver belongs in the shared upload base, applied to both placements by construction.
  • TreatWarningsAsErrors=true is deliberate — do not relax it.
  • dotnet test --filter is inert here (MTP). Use dotnet test <csproj> -c Release -- --filter-class <FQN>, or the built host directly; see .claude/skills/repo-profile.mdBuild & test.

Task 1: Pin the defect with a failing test

Files: create/extend FormCraft.ForMudBlazor.UnitTests/Fields/FileUploadConstraintTests.cs.

Interfaces: none — bUnit render assertions over MudBlazorFileUploadFieldComponent.

  • Step 1: Write a failing test: build a field with .AsFileUpload(acceptedFileTypes: [".pdf"], maxFileSize: 5 * 1024 * 1024), render the single-file MudBlazor component, and assert the resolved accept filter and max size are the configured ones.
  • Step 2: Run it → FAIL (both null today). Record the output.
  • Step 3: Add the mirror assertion for the multiple-file component and confirm it already passes — that contrast is the bug, and it is what the parity test in Task 3 generalises.
  • Step 4: Commit: test(mudblazor): pin that AsFileUpload constraints reach the single-file upload.

Task 2: One resolver, consumed by every upload component

Files: modify FormCraft.ForMudBlazor/Fields/FileUploadField/MudBlazorFileUploadComponentBase.cs (add the resolver), MudBlazorFileUploadFieldComponent.razor.cs, MudBlazorMultipleFileUploadComponent.razor.cs, FormCraft.ForFluentUI/Fields/FileUploadField/FluentUIFileUploadComponentBase.cs.

Interfaces: a protected resolver on the shared upload base returning the effective Accept / MaxFileSize / MaxFiles / ShowPreview / EnableDragDrop, resolving FileUploadConfiguration first and raw attributes second.

  • Step 1: Implement the resolver on the shared upload base: read FileUploadConfiguration, fall back to the raw keys, and coerce numeric sizes so a boxed int binds where a long is expected.
  • Step 2: Point MudBlazorFileUploadFieldComponent.OnInitialized at the resolver instead of its raw-key reads.
  • Step 3: Point MudBlazorMultipleFileUploadComponent at the same resolver, preserving its current behaviour (it is the one that works today — it must not regress).
  • Step 4: Point FluentUIFileUploadComponentBase at it, removing the 10L-typed fallback that drops int values.
  • Step 5: Re-run Task 1's tests → PASS.
  • Step 6: Correct or delete the now-misleading comment in FieldBuilderExtensions.AsFileUpload claiming the component reads the configuration attribute.
  • Step 7: Commit: fix(upload): resolve constraints through one path in every upload component.

Task 3: Parity guard + full verification

Files: extend the test file from Task 1 (and the Fluent equivalent under FormCraft.ForFluentUI.UnitTests).

Interfaces: none.

  • Step 1: Write a parity test asserting one .AsFileUpload(...) configuration resolves to the same constraints on every upload component — the drift, not any single reading, is what must fail.
  • Step 2: Add a case for .WithAttribute("MaxFileSize", 5_000_000) (an int) binding rather than falling back to a default.
  • Step 3: Run both adapters' suites via the per-class filter → green.
  • Step 4: Run ./build.sh Test (CI gate) → all suites green under TreatWarningsAsErrors.
  • Step 5: Commit: test(upload): assert every upload component resolves the same constraints.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions