EditModalSaveContextHolder is required by ModelView but registered by no BlazorBlocks/AuthBlocks setup extension. Recommends AddBlazorBlocksWeb() called from ConfigureAuthServices.
19 KiB
Team Brief — Fix the Missing EditModalSaveContextHolder DI Registration in BlazorBlocks / AuthBlocks
Audience: an orchestrator (and its implementers) working in the BlazorBlocks repository at
C:\Development\BlazorBlocks and/or the AuthBlocks repository at C:\Development\AuthBlocks.
You do not need, and should not assume, any knowledge of the products that consume these libraries.
Everything you need is in this brief or in those two repos.
Status: scoped request, not yet started. Confirmed at runtime against Cerebellum.AuthBlocksWeb
10.3.33 / Cerebellum.BlazorBlocks.Web 10.3.32. Author: product-designer (for a downstream consumer team).
Date: 2026-06-19.
1. The defect in one sentence
The BlazorBlocks ModelView component (and the EditModelModal it drives) has a required [Inject]
dependency on Web.Maintenance.Entities.EditModalSaveContextHolder, but no BlazorBlocks setup
extension and no AuthBlocks setup extension registers that service — so any consumer that surfaces a
page built on ModelView gets an unhandled InvalidOperationException that terminates the Blazor
circuit on navigation, unless the consumer manually hand-registers the internal service in its own
Program.cs.
The fix is an upstream library fix, delivered as a version bump. The goal: a consumer that calls
AuthBlocks' single DI entry point AuthBlocksWeb.Startup.ConfigureAuthServices(...) gets a fully
working user-admin surface with zero manual service registrations.
2. The confirmed failure
Stack trace (captured from a consuming app on navigation to the Users admin page)
System.InvalidOperationException: Cannot provide a value for property 'SaveContextHolder' on type
'Web.Maintenance.Entities.ModelView`5[[AuthBlocksModels.InputModels.UserInputModel, ...],
[AuthBlocksModels.Models.UserModel, ...],[AuthBlocksWeb.Components.Pages.UserAdmin.Users.UserEditModal, ...],
[AuthBlocksWeb.Components.Pages.UserAdmin.Users.UsersViewModel, ...],
[AuthBlocksModels.Converters.UserModelToInputConverter, ...]]'.
There is no registered service of type 'Web.Maintenance.Entities.EditModalSaveContextHolder'.
at Microsoft.AspNetCore.Components.ComponentFactory...CreatePropertyInjector...
Because SaveContextHolder is declared required with [Inject], Blazor's component factory throws
during component activation. There is no try/catch around component instantiation in the render path, so
the exception propagates and tears down the circuit. The user sees a dead page / "An unhandled error
has occurred" and must reload.
Which AuthBlocks pages trigger it
AuthBlocksWeb ships two user-admin pages that render <ModelView>:
AuthBlocksWeb/Components/Pages/UserAdmin/Users/Users.razorAuthBlocksWeb/Components/Pages/UserAdmin/Registrations/Registrations.razor
Any consumer that routes to either page hits the defect on first navigation.
3. Why this is an upstream gap, not a consumer mistake (the two-consumer tell)
Two independent downstream products have each had to hand-register the same internal BlazorBlocks service to make AuthBlocks' shipped pages work:
- SkipperHaven —
C:\Development\skipper\SkipperHaven\SkipperHaven\Program.cs:129:(with a comment explaining it bridges the per-page save callback frombuilderServices.AddScoped<Web.Maintenance.Entities.EditModalSaveContextHolder>();ModelViewinto the genericEditModelModal.) - A second consumer added the identical
AddScoped<...EditModalSaveContextHolder>()workaround as a stopgap.
When two unrelated consumers independently discover they must reach into a library's internal namespace
(Web.Maintenance.Entities) and register a type the library never documented as a consumer
responsibility, that is a leaked registration. A library that ships components requiring a service its
own setup extension doesn't register is pushing its wiring onto every consumer. The correct owner of the
registration is the library, not the application.
4. Root-cause findings (from reading the source)
4.1 The service and who needs it
EditModalSaveContextHolder (C:\Development\BlazorBlocks\Web\Maintenance\Entities\EditModalSaveContextHolder.cs)
is a tiny per-circuit slot:
public sealed class EditModalSaveContextHolder
{
public IEditModalSaveContext? Current { get; set; }
}
It is injected as required in two library components:
Web/Maintenance/Entities/ModelView.razor.cs:42— setsSaveContextHolder.Currentbefore opening the edit dialog and clears it in afinally(seeEditItem, lines 137–172).Web/Maintenance/Entities/EditModelModal.razor:40— readsSaveContextHolder.Currentto obtain the typed save callback (SaveContext).
The holder is deliberately the bridge between these two components — it threads a save closure from the
page-side ModelView into the generic EditModelModal without forcing a parameter through every
per-page modal wrapper. So both components fail without it; the bug surfaces at ModelView
activation simply because that component is constructed first.
4.2 Is there already a BlazorBlocks registration extension to fix?
No Web-side registration extension exists. A full scan of BlazorBlocks for
IServiceCollection extension methods finds only:
Data.Postgres/ServiceCollectionExtensions.cs→AddBlazorBlocksPostgres()(registersIDbExceptionClassifier; a data-layer concern, unrelated to the Web components).API/Errors/...AddResultMessagePolymorphism(...)(a JSON resolver helper, not DI).
There is no AddBlazorBlocks() / AddBlazorBlocksWeb() / AddMaintenance() method in the Web
project (Cerebellum.BlazorBlocks.Web). So EditModalSaveContextHolder is not "missing from an existing
extension" — there is no Web-side extension at all. The library ships components with a hard DI
dependency and provides no entry point to register that dependency.
4.3 Is EditModalSaveContextHolder the only missing dependency, or will the next page hit a second?
It is the only library-owned missing registration. I enumerated every [Inject] across the
BlazorBlocks Web/Maintenance tree:
| Component | Injected types |
|---|---|
ModelView<...> |
TViewModel (consumer), NavigationManager (framework), IDialogService (MudBlazor), ISnackbar (MudBlazor), EditModalSaveContextHolder (BlazorBlocks — UNREGISTERED) |
EditModelModal<TModel> |
ISnackbar (MudBlazor), EditModalSaveContextHolder (BlazorBlocks — UNREGISTERED) |
NewModelView<...> |
TClient (consumer), NavigationManager (framework), IDialogService (MudBlazor), ISnackbar (MudBlazor) |
Everything else resolves through services consumers already register:
NavigationManager— Blazor framework.IDialogService,ISnackbar— MudBlazor, registered by the consumer'sAddMudServices()(SkipperHaven calls it atProgram.cs:36; it is a documented MudBlazor prerequisite).TViewModel/TClient— the per-entity viewmodel/client, registered by AuthBlocks'ConfigureAuthServices(e.g.UsersViewModel,RegistrationsViewModel) or by the consumer for its own entities.
So EditModalSaveContextHolder is the single gap. The next user-admin page will not hit a second
missing registration, provided the consumer has already called AddMudServices() (which AuthBlocks
already implicitly requires for all its MudBlazor-based pages). Registering the one holder closes the
whole set.
4.4 The AuthBlocks side
AuthBlocksWeb/Startup.cs exposes ConfigureAuthServices(IServiceCollection, string apiBaseUrl) — the
single DI entry point consumers call to light up the AuthBlocks Web surface. It registers all the
user-admin viewmodels and clients (UsersViewModel, RegistrationsViewModel, PermissionsViewModel,
their clients, auth state, hierarchical authorization, etc.). It does not register
EditModalSaveContextHolder, and it does not call any BlazorBlocks Web extension (there is none to
call). So AuthBlocks ships pages that depend on ModelView while leaving one of ModelView's required
services unregistered — the consumer is silently expected to fill the gap, which is exactly what the two
consumers discovered the hard way.
5. Recommended fix
Do both, layered — the registration lives in BlazorBlocks; AuthBlocks calls it.
5.1 BlazorBlocks: add a Web-side registration extension (the owner of the type registers the type)
Add an IServiceCollection extension in the Web project that registers the maintenance components'
library-owned dependencies. This is where EditModalSaveContextHolder belongs — it is BlazorBlocks'
internal bridge, and any product using ModelView (with or without AuthBlocks) needs it.
// C:\Development\BlazorBlocks\Web\ServiceCollectionExtensions.cs (new)
using Microsoft.Extensions.DependencyInjection;
using Web.Maintenance.Entities;
namespace Web;
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers the services required by the BlazorBlocks maintenance UI
/// components (ModelView / EditModelModal / NewModelView). Call this in
/// your application's DI setup when using those components.
/// Note: MudBlazor (AddMudServices) is a separate, caller-owned prerequisite.
/// </summary>
public static IServiceCollection AddBlazorBlocksWeb(this IServiceCollection services)
{
// Per-circuit slot bridging ModelView's save callback into EditModelModal.
services.AddScoped<EditModalSaveContextHolder>();
return services;
}
}
Pick the exact method name to match the house convention (AddBlazorBlocksPostgres already exists in the
data package, so AddBlazorBlocksWeb is the consistent sibling). The method should be the single place
the maintenance components' library-owned deps are registered, so any future ModelView dependency is
added here once and every consumer picks it up on the next bump.
5.2 AuthBlocks: call the BlazorBlocks extension from ConfigureAuthServices
So that AuthBlocks stays self-contained for its consumers (the whole value of a single
ConfigureAuthServices entry point), have ConfigureAuthServices call AddBlazorBlocksWeb():
// AuthBlocksWeb/Startup.cs, inside ConfigureAuthServices(...)
services.AddBlazorBlocksWeb(); // registers EditModalSaveContextHolder for the ModelView-based pages
Place it near the top, before/after the existing registrations — order does not matter for this scoped
service. This means a consumer that calls only ConfigureAuthServices (plus the already-required
AddMudServices) gets a fully working Users/Registrations admin surface with no manual registrations.
5.3 Why both, and not just one
- BlazorBlocks-only would fix it for direct
ModelViewconsumers, but AuthBlocks consumers who only callConfigureAuthServiceswould still have to also callAddBlazorBlocksWeb()themselves — the gap just moves one layer up and stays a leaked registration. - AuthBlocks-only (register the holder directly inside
ConfigureAuthServices) would fix the AuthBlocks consumers, but a product using BlazorBlocksModelViewwithout AuthBlocks would still hit the bug, and AuthBlocks would be reaching into BlazorBlocks' internal namespace to register a type it doesn't own — the same smell the two consumers exhibited, just relocated. - Both puts the registration with its owner (BlazorBlocks) and makes AuthBlocks self-contained by
composing the owner's extension. This is the standard ASP.NET Core layering: each library exposes an
Add*that registers its own services; a higher-level library'sAdd*calls the lower one's.
6. Lifetime note (confirm and call out)
Both current consumers register the holder as AddScoped (per-circuit). Scoped is correct and the
extension above must use AddScoped, not singleton or transient:
- The holder is per-circuit mutable state —
ModelView.EditItemwritesCurrentimmediately before opening the dialog and nulls it infinally;EditModelModalreads it. A singleton would share one slot across all users/circuits and cross-contaminate concurrent edits — a correctness/security bug. - A transient would hand
ModelViewandEditModelModaldifferent instances, so the modal would never see the context the view set — the bridge would silently no-op and edits would fall back to the legacy "close with model" path (seeEditModelModal.Submit, theSaveContext is nullbranch). - The type's own XML doc states it is "Scoped per circuit."
AddScopedin Blazor Server = one instance per circuit, which is exactly the intended semantics.
So: AddScoped<EditModalSaveContextHolder>(), and document the scoped requirement in the extension's
summary so it is not "tidied" to singleton later.
7. Downstream cleanup (after the fix ships)
Once BlazorBlocks Web and AuthBlocks Web are fixed and version-bumped, and consumers pin the new
versions:
- SkipperHaven can delete the manual line at
Program.cs:129(builderServices.AddScoped<Web.Maintenance.Entities.EditModalSaveContextHolder>();) and its comment. - The second consumer can delete its identical stopgap line.
These deletions are out of scope for this brief (they are consumer-side, done by each consumer team after picking up the bump) but are listed so the fix's "done" state is unambiguous: the workaround should no longer exist anywhere. Verify nothing else in either consumer references the holder directly before removing.
8. Constraints
- Use
AddScopedforEditModalSaveContextHolder— not singleton, not transient (§6). - Do not change
ModelView/EditModelModalto drop therequired/[Inject]— the holder is the intentional design (the bridge described in §4.1, and in the type's own doc comment). The fix is to register the dependency, not to remove it. - MudBlazor remains a caller-owned prerequisite.
AddBlazorBlocksWebmust not callAddMudServices()— consumers configure MudBlazor (theme, snackbar options) themselves, and double- registration would clobber their config. DocumentAddMudServicesas a prerequisite in the extension's summary, do not absorb it. - Keep
ConfigureAuthServicesthe single AuthBlocks entry point. Do not introduce a second method consumers must remember to call; fold the BlazorBlocks call into the existing one. - Versioning: both libraries pack/push via
pack.ps1(C:\Development\BlazorBlocks\pack.ps1and the AuthBlocks equivalent). BlazorBlocksWebis currentlyCerebellum.BlazorBlocks.Web10.3.32; AuthBlocks Web is 10.3.33. Bump BlazorBlocks first, then bump AuthBlocks to reference the new BlazorBlocks version and add theAddBlazorBlocksWeb()call. Record both new versions so consumers can pin. - Order of operations: BlazorBlocks bump must land and be referenced by AuthBlocks before the
AuthBlocks bump can call
AddBlazorBlocksWeb().
9. Acceptance criteria
- A fresh consumer that calls
AddMudServices()andAuthBlocksWeb.Startup.ConfigureAuthServices(...)— and registers nothing else by hand — can navigate to the Users admin page and the Registrations admin page with noInvalidOperationExceptionand no circuit teardown. - Opening the edit dialog on a user, saving a valid change, succeeds — i.e. the save bridge actually
works (the holder is scoped correctly so
EditModelModalsees the contextModelViewset), not merely "the page loads." - A product that uses BlazorBlocks
ModelViewwithout AuthBlocks can register the maintenance deps via the newAddBlazorBlocksWeb()(single call) and gets the same working behavior. - The new BlazorBlocks
Webextension registersEditModalSaveContextHolderas scoped, with a summary documenting the scoped requirement and theAddMudServicesprerequisite. ConfigureAuthServicescalls the BlazorBlocks extension; no AuthBlocks consumer needs to touchWeb.Maintenance.Entitiesdirectly.- Both libraries are published as version bumps; the new versions are recorded.
- (Verification, not a code change) The two consumer stopgaps can be deleted and the pages still work.
10. Open questions for the implementing team / its sponsor
- Extension method name.
AddBlazorBlocksWeb()is proposed for consistency with the existingAddBlazorBlocksPostgres(). Confirm, or pick the preferred house name (e.g.AddBlazorBlocksMaintenance()if the team expects to split Web concerns further). Recommendation:AddBlazorBlocksWeb(). - Scope of the extension. Register only
EditModalSaveContextHoldernow (the only current gap), or pre-emptively makeAddBlazorBlocksWeb()the home for all future maintenance-component deps? Recommendation: ship it with just the holder now, but frame it (name + doc) as the general home so future deps land in one place — no speculative registrations. - Should
AddBlazorBlocksWebbe idempotent / safe to double-call? If a consumer calls it directly and viaConfigureAuthServices, a plainAddScopedregisters the service twice (last wins; harmless for this slot, but slightly untidy). ConsiderTryAddScopedto make it idempotent. Recommendation: useTryAddScopedso double-registration is a no-op. - Are there other BlazorBlocks components elsewhere (outside
Web/Maintenance) with unregistered library-owned[Inject]deps? This brief scoped the audit to the maintenance tree that AuthBlocks' pages use. If the team wants a clean bill of health, grep the wholeWebproject for[Inject]of BlazorBlocks-owned types and fold any others into the same extension. Recommendation: do the quick full-project grep while you are in here; cheap insurance.
11. Suggested reading order in the repos
BlazorBlocks (C:\Development\BlazorBlocks):
Web/Maintenance/Entities/EditModalSaveContextHolder.cs— the unregistered service (and its scoped doc).Web/Maintenance/Entities/ModelView.razor.cs—[Inject]at line 42;EditItem(137–172) shows the holder being written/cleared around the dialog.Web/Maintenance/Entities/EditModelModal.razor—[Inject]at line 40;Submitshows the holder being read (and theSaveContext is nullfallback that masks the bug into a wrong-behavior path if the lifetime is botched).Data.Postgres/ServiceCollectionExtensions.cs— the existingAdd*convention to mirror.Web/Web.csproj— package idCerebellum.BlazorBlocks.Web, current version, where to add the newServiceCollectionExtensions.cs.pack.ps1— the pack/push flow for the version bump.
AuthBlocks (C:\Development\AuthBlocks):
AuthBlocksWeb/Startup.cs—ConfigureAuthServices, the single entry point; add theAddBlazorBlocksWeb()call here.AuthBlocksWeb/Components/Pages/UserAdmin/Users/Users.razorand.../UserAdmin/Registrations/Registrations.razor— the two pages that render<ModelView>and trigger the defect.- The AuthBlocks
pack.ps1/ packaging script — to bump AuthBlocks Web after it references the new BlazorBlocks version.