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
DynamicFormValidator<TModel> cannot name the item type of a collection — TItem exists only on CollectionFieldValidator<TModel, TItem> — so it constructs and calls that validator reflectively.
That one untyped seam produces two costs, neither user-visible today.
1. Every call is still a reflective dispatch
#331 cached the plumbing per configuration, so MakeGenericType, Activator.CreateInstance and GetMethod now run once. The calls themselves did not change:
Every collection keystroke therefore pays a MethodInfo.Invoke, an object[] allocation and a boxed itemIndex, and the model parameter is widened to object — so a signature change on CollectionFieldValidator fails at runtime, not at compile time. The invoker's constructor throws on
an unresolvable method precisely because that class of mistake cannot be caught by the compiler here.
2. The collection is resolved more than once per pass
ValidateAllAsync awaits ValidateItemsAsync, which calls _configuration.CollectionAccessor(model),
and then calls BuildMessages, which calls the accessor again for the item count
(FormCraft/Forms/Validators/CollectionFieldValidator.cs).
For an ordinary auto-property that is a wasted call. For a property that materialises per get —
publicList<Item>Items=> _set.ToList();// a new list on every access
— the two calls return different lists, so the MinItems/MaxItems rules are measured against a
different snapshot than the items that were validated. The messages then describe two different
collections.
Pre-existing: the old ValidateAsync had the same double resolution. #331 made it explicit by
extracting BuildMessages, without changing it.
Proposed solution
Close the seam rather than cache around it.
Give CollectionFieldValidator<TModel, TItem> a non-generic surface — an interface exposing ValidateAllAsync(object model, IServiceProvider services) and ValidateItemFieldAsync(object model, int itemIndex, string fieldName, IServiceProvider services), or
a CreateValidator() factory on ICollectionFieldConfigurationBase that returns it. DynamicFormValidator
then holds something directly callable: both Invokes, both casts, the object[]s, the boxing and the
constructor's defensive throws all disappear, and a signature change becomes a compile error.
Resolve the collection once per pass — have ValidateAllAsync read the accessor once and thread
the resulting list into both the item traversal and the count rules. Note this changes the shape of the
public ValidateItemsAsync, which currently resolves its own; keep that method working for external
callers.
Neither change alters behaviour, so the existing suites are the contract.
Alternatives considered
Leave it. Defensible: fix(core): validate each collection item field once per pass (#329) #331 already removed the per-call type resolution, which was the bulk of
the cost, and nothing is user-visible. The argument against is the type safety, not the speed — this
is the only place in the validation path where a wrong signature reaches production as a runtime
failure.
Source-generate a typed dispatcher. Removes reflection entirely with no interface to design, but
adds a generator to a library that has none, for one call site.
Fix only the double resolution. The smaller half and independently correct, but it leaves the
seam that motivates the whole issue.
This is the residue of a sequence: #269 cached the render path's compiled getter, #312 extended that to
validation and unified both onto one cache, #329 stopped collection validation running twice and cached
its reflective plumbing. Each removed the dominant cost of its day. What is left is the mechanism —
reflection as the calling convention between a component that knows TModel and a validator that also
needs TItem.
#314 is the same shape one layer over: RenderField resolving the field type by reflection per render.
Whatever pattern resolves this one is worth reusing there.
Approaches
A. Non-generic interface on the validator. Pros: deletes every reflective call and restores compile-time checking; small and local; the invoker
class in DynamicFormValidator collapses to a field. Cons: an object-typed interface is itself
mildly untyped — it moves the cast inside the validator rather than removing it.
B. CreateValidator() factory on ICollectionFieldConfigurationBase. Pros: the configuration already knows its TItem, so it is the natural place to construct the
validator; removes Activator.CreateInstance too. Cons: widens a configuration interface with a
behavioural method, which is a heavier abstraction than the problem needs.
C. Cache more aggressively (compiled delegates over MethodInfo). Pros: no API change at all. Cons: keeps the runtime-failure mode, and trades reflection for
expression-compilation — the exact cost #269/#312 spent two PRs removing.
Recommendation
A, with the accessor fix folded in since both live in the same two files and neither changes
behaviour. C is rejected on principle: adding Expression.Compile() back into this path to avoid MethodInfo.Invoke would be undoing #312 to optimise #329.
📋 Spec
Goal
DynamicFormValidator calls the collection validator through a typed surface, and one validation pass
reads the collection once.
Scope
A non-generic surface on CollectionFieldValidator<TModel, TItem> that DynamicFormValidator can
hold and call directly.
One accessor call per pass, threaded into both the item traversal and the count rules.
A null collection must short-circuit as it does today, before any count rule runs.
ValidateItemsAsync called directly by an external consumer keeps resolving its own collection.
Assumptions
No external consumer depends on the reflective shape (nobody is calling these through reflection
themselves), so introducing a typed surface is additive.
🛠️ 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: replace the reflective calling convention with a typed one, and read the collection once per
pass.
Architecture: all changes in FormCraft (core). No UI-framework types. Behaviour-preserving — the
existing suites are the contract.
Base branch dev; commit as Philippe Matray <phmatray@gmail.com>; conventional commits.
TreatWarningsAsErrors=true — the build fails on any warning.
Per-suite filter: dotnet test <project>.csproj -c Release -- --filter-class <FQN> (never dotnet test --filter). Run the full suite before claiming done.
Step 1: Write a failing test using a model whose collection property returns a new list per access (=> _set.ToList()), with MinItems set so the count rule fires, asserting the count message and the item messages describe the same snapshot.
Step 2: Run the suite → observe the failure and record in the test what it actually reports today; if the double resolution happens to agree for a stable backing set, make the property's contents differ per access so the inconsistency is forced.
Step 3: Commit: test(core): pin that collection count and item messages share one snapshot.
Interfaces: an internal traversal taking the resolved list; ValidateItemsAsync keeps its public shape.
Step 1: Extract the item traversal so it accepts an already-resolved list, and have ValidateAllAsync resolve once and pass it to both the traversal and BuildMessages.
Step 2: Keep the public ValidateItemsAsync resolving its own collection and delegating, so external callers are unaffected.
Step 3: Run the suite → Task 1's test PASSES and every existing suite still does.
Step 4: Commit: fix(core): read a collection once per validation pass.
Task 3: Replace the reflective calls with a typed surface
Files: modify FormCraft/Forms/Validators/CollectionFieldValidator.cs and FormCraft/Forms/Validation/DynamicFormValidator.cs; add the new interface.
Interfaces: a non-generic surface exposing ValidateAllAsync(object, IServiceProvider) and ValidateItemFieldAsync(object, int, string, IServiceProvider).
Step 1: Decide between the non-generic interface and a CreateValidator() factory on ICollectionFieldConfigurationBase, and record the reasoning in the surviving type's XML docs.
Step 2: Implement it on CollectionFieldValidator<TModel, TItem>, casting the object model once at the boundary.
Step 3: Replace CollectionValidatorInvoker's MethodInfo.Invoke calls with direct interface calls, deleting the GetMethod lookups, the casts, the object[]s and the constructor's defensive throws that only existed because the compiler could not check this.
Problem / motivation
DynamicFormValidator<TModel>cannot name the item type of a collection —TItemexists only onCollectionFieldValidator<TModel, TItem>— so it constructs and calls that validator reflectively.That one untyped seam produces two costs, neither user-visible today.
1. Every call is still a reflective dispatch
#331 cached the plumbing per configuration, so
MakeGenericType,Activator.CreateInstanceandGetMethodnow run once. The calls themselves did not change:Every collection keystroke therefore pays a
MethodInfo.Invoke, anobject[]allocation and a boxeditemIndex, and the model parameter is widened toobject— so a signature change onCollectionFieldValidatorfails at runtime, not at compile time. The invoker's constructor throws onan unresolvable method precisely because that class of mistake cannot be caught by the compiler here.
2. The collection is resolved more than once per pass
ValidateAllAsyncawaitsValidateItemsAsync, which calls_configuration.CollectionAccessor(model),and then calls
BuildMessages, which calls the accessor again for the item count(
FormCraft/Forms/Validators/CollectionFieldValidator.cs).For an ordinary auto-property that is a wasted call. For a property that materialises per get —
— the two calls return different lists, so the MinItems/MaxItems rules are measured against a
different snapshot than the items that were validated. The messages then describe two different
collections.
Pre-existing: the old
ValidateAsynchad the same double resolution. #331 made it explicit byextracting
BuildMessages, without changing it.Proposed solution
Close the seam rather than cache around it.
Give
CollectionFieldValidator<TModel, TItem>a non-generic surface — an interface exposingValidateAllAsync(object model, IServiceProvider services)andValidateItemFieldAsync(object model, int itemIndex, string fieldName, IServiceProvider services), ora
CreateValidator()factory onICollectionFieldConfigurationBasethat returns it.DynamicFormValidatorthen holds something directly callable: both
Invokes, both casts, theobject[]s, the boxing and theconstructor's defensive throws all disappear, and a signature change becomes a compile error.
Resolve the collection once per pass — have
ValidateAllAsyncread the accessor once and threadthe resulting list into both the item traversal and the count rules. Note this changes the shape of the
public
ValidateItemsAsync, which currently resolves its own; keep that method working for externalcallers.
Neither change alters behaviour, so the existing suites are the contract.
Alternatives considered
the cost, and nothing is user-visible. The argument against is the type safety, not the speed — this
is the only place in the validation path where a wrong signature reaches production as a runtime
failure.
adds a generator to a library that has none, for one call site.
seam that motivates the whole issue.
Area
FormCraft — core validation pipeline
Follow-up from #331. Related: #329, #312, #314
🧠 Brainstorm
Problem / context
This is the residue of a sequence: #269 cached the render path's compiled getter, #312 extended that to
validation and unified both onto one cache, #329 stopped collection validation running twice and cached
its reflective plumbing. Each removed the dominant cost of its day. What is left is the mechanism —
reflection as the calling convention between a component that knows
TModeland a validator that alsoneeds
TItem.#314 is the same shape one layer over:
RenderFieldresolving the field type by reflection per render.Whatever pattern resolves this one is worth reusing there.
Approaches
A. Non-generic interface on the validator.
Pros: deletes every reflective call and restores compile-time checking; small and local; the invoker
class in
DynamicFormValidatorcollapses to a field. Cons: anobject-typed interface is itselfmildly untyped — it moves the cast inside the validator rather than removing it.
B.
CreateValidator()factory onICollectionFieldConfigurationBase.Pros: the configuration already knows its
TItem, so it is the natural place to construct thevalidator; removes
Activator.CreateInstancetoo. Cons: widens a configuration interface with abehavioural method, which is a heavier abstraction than the problem needs.
C. Cache more aggressively (compiled delegates over
MethodInfo).Pros: no API change at all. Cons: keeps the runtime-failure mode, and trades reflection for
expression-compilation — the exact cost #269/#312 spent two PRs removing.
Recommendation
A, with the accessor fix folded in since both live in the same two files and neither changes
behaviour. C is rejected on principle: adding
Expression.Compile()back into this path to avoidMethodInfo.Invokewould be undoing #312 to optimise #329.📋 Spec
Goal
DynamicFormValidatorcalls the collection validator through a typed surface, and one validation passreads the collection once.
Scope
CollectionFieldValidator<TModel, TItem>thatDynamicFormValidatorcanhold and call directly.
Non-goals
GetActualFieldType) — that is RenderField resolves the field type by reflection on every render #314.ValidateAsync/ValidateItemsAsyncfrom the public surface.Behaviour
flowchart LR subgraph before A1[DynamicFormValidator] -->|MethodInfo.Invoke<br/>object[] + boxing| B1["CollectionFieldValidator<TModel,TItem>"] B1 --> C1[accessor call #1<br/>item traversal] B1 --> D1[accessor call #2<br/>count rules] end subgraph after A2[DynamicFormValidator] -->|typed interface call| B2["CollectionFieldValidator<TModel,TItem>"] B2 --> C2[one accessor call] C2 --> D2[traversal + count rules] endKey files
FormCraft/Forms/Validation/DynamicFormValidator.cs—CollectionValidatorInvokerand its two callsites.
FormCraft/Forms/Validators/CollectionFieldValidator.cs—ValidateAllAsync,ValidateItemsAsync,BuildMessages.FormCraft/Forms/Core/ICollectionFieldConfiguration.cs— only if the factory approach is chosen.Validation rules
describing the same snapshot.
Items[i].Fieldattribution is unchanged (Improve nested field identification for collection fields using Blazor's FieldIdentifier system #91).Edge cases
configuration instance for this reason, and a factory approach must preserve it.
ValidateItemsAsynccalled directly by an external consumer keeps resolving its own collection.Assumptions
themselves), so introducing a typed surface is additive.
🛠️ Implementation plan
Goal: replace the reflective calling convention with a typed one, and read the collection once per
pass.
Architecture: all changes in
FormCraft(core). No UI-framework types. Behaviour-preserving — theexisting suites are the contract.
Tech stack: .NET 8 / 10 multi-target, xUnit + Shouldly.
Global constraints:
dev; commit asPhilippe Matray <phmatray@gmail.com>; conventional commits.TreatWarningsAsErrors=true— the build fails on any warning.dotnet test <project>.csproj -c Release -- --filter-class <FQN>(neverdotnet test --filter). Run the full suite before claiming done.Task 1: Pin the snapshot consistency the accessor fix must deliver
Files: modify
FormCraft.UnitTests/Validators/CollectionFieldValidatorTests.cs.Interfaces: none — tests only.
=> _set.ToList()), withMinItemsset so the count rule fires, asserting the count message and the item messages describe the same snapshot.test(core): pin that collection count and item messages share one snapshot.Task 2: Resolve the collection once per pass
Files: modify
FormCraft/Forms/Validators/CollectionFieldValidator.cs.Interfaces: an internal traversal taking the resolved list;
ValidateItemsAsynckeeps its public shape.ValidateAllAsyncresolve once and pass it to both the traversal andBuildMessages.ValidateItemsAsyncresolving its own collection and delegating, so external callers are unaffected.fix(core): read a collection once per validation pass.Task 3: Replace the reflective calls with a typed surface
Files: modify
FormCraft/Forms/Validators/CollectionFieldValidator.csandFormCraft/Forms/Validation/DynamicFormValidator.cs; add the new interface.Interfaces: a non-generic surface exposing
ValidateAllAsync(object, IServiceProvider)andValidateItemFieldAsync(object, int, string, IServiceProvider).CreateValidator()factory onICollectionFieldConfigurationBase, and record the reasoning in the surviving type's XML docs.CollectionFieldValidator<TModel, TItem>, casting theobjectmodel once at the boundary.CollectionValidatorInvoker'sMethodInfo.Invokecalls with direct interface calls, deleting theGetMethodlookups, the casts, theobject[]s and the constructor's defensive throws that only existed because the compiler could not check this.dotnet build -c Releaseand the fulldotnet test -c Release→ both green, no warnings, all three assemblies reporting.refactor(core): call the collection validator through a typed surface.