Files
deepdrft/product-notes/blazorblocks-modelview-di-registration-brief.md
T
daniel-c-harvey b90604d311 docs: add brief for upstream BlazorBlocks ModelView DI-registration fix
EditModalSaveContextHolder is required by ModelView but registered by no BlazorBlocks/AuthBlocks setup extension. Recommends AddBlazorBlocksWeb() called from ConfigureAuthServices.
2026-06-19 23:16:29 -04:00

19 KiB
Raw Blame History

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.razor
  • AuthBlocksWeb/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:

  • SkipperHavenC:\Development\skipper\SkipperHaven\SkipperHaven\Program.cs:129:
    builderServices.AddScoped<Web.Maintenance.Entities.EditModalSaveContextHolder>();
    
    (with a comment explaining it bridges the per-page save callback from ModelView into the generic EditModelModal.)
  • 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 — sets SaveContextHolder.Current before opening the edit dialog and clears it in a finally (see EditItem, lines 137172).
  • Web/Maintenance/Entities/EditModelModal.razor:40 — reads SaveContextHolder.Current to 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.csAddBlazorBlocksPostgres() (registers IDbExceptionClassifier; 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's AddMudServices() (SkipperHaven calls it at Program.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.


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 ModelView consumers, but AuthBlocks consumers who only call ConfigureAuthServices would still have to also call AddBlazorBlocksWeb() 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 BlazorBlocks ModelView without 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's Add* 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 stateModelView.EditItem writes Current immediately before opening the dialog and nulls it in finally; EditModelModal reads it. A singleton would share one slot across all users/circuits and cross-contaminate concurrent edits — a correctness/security bug.
  • A transient would hand ModelView and EditModelModal different 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 (see EditModelModal.Submit, the SaveContext is null branch).
  • The type's own XML doc states it is "Scoped per circuit." AddScoped in 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 AddScoped for EditModalSaveContextHolder — not singleton, not transient (§6).
  • Do not change ModelView / EditModelModal to drop the required/[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. AddBlazorBlocksWeb must not call AddMudServices() — consumers configure MudBlazor (theme, snackbar options) themselves, and double- registration would clobber their config. Document AddMudServices as a prerequisite in the extension's summary, do not absorb it.
  • Keep ConfigureAuthServices the 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.ps1 and the AuthBlocks equivalent). BlazorBlocks Web is currently Cerebellum.BlazorBlocks.Web 10.3.32; AuthBlocks Web is 10.3.33. Bump BlazorBlocks first, then bump AuthBlocks to reference the new BlazorBlocks version and add the AddBlazorBlocksWeb() 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

  1. A fresh consumer that calls AddMudServices() and AuthBlocksWeb.Startup.ConfigureAuthServices(...) — and registers nothing else by hand — can navigate to the Users admin page and the Registrations admin page with no InvalidOperationException and no circuit teardown.
  2. 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 EditModelModal sees the context ModelView set), not merely "the page loads."
  3. A product that uses BlazorBlocks ModelView without AuthBlocks can register the maintenance deps via the new AddBlazorBlocksWeb() (single call) and gets the same working behavior.
  4. The new BlazorBlocks Web extension registers EditModalSaveContextHolder as scoped, with a summary documenting the scoped requirement and the AddMudServices prerequisite.
  5. ConfigureAuthServices calls the BlazorBlocks extension; no AuthBlocks consumer needs to touch Web.Maintenance.Entities directly.
  6. Both libraries are published as version bumps; the new versions are recorded.
  7. (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

  1. Extension method name. AddBlazorBlocksWeb() is proposed for consistency with the existing AddBlazorBlocksPostgres(). Confirm, or pick the preferred house name (e.g. AddBlazorBlocksMaintenance() if the team expects to split Web concerns further). Recommendation: AddBlazorBlocksWeb().
  2. Scope of the extension. Register only EditModalSaveContextHolder now (the only current gap), or pre-emptively make AddBlazorBlocksWeb() 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.
  3. Should AddBlazorBlocksWeb be idempotent / safe to double-call? If a consumer calls it directly and via ConfigureAuthServices, a plain AddScoped registers the service twice (last wins; harmless for this slot, but slightly untidy). Consider TryAddScoped to make it idempotent. Recommendation: use TryAddScoped so double-registration is a no-op.
  4. 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 whole Web project 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):

  1. Web/Maintenance/Entities/EditModalSaveContextHolder.cs — the unregistered service (and its scoped doc).
  2. Web/Maintenance/Entities/ModelView.razor.cs[Inject] at line 42; EditItem (137172) shows the holder being written/cleared around the dialog.
  3. Web/Maintenance/Entities/EditModelModal.razor[Inject] at line 40; Submit shows the holder being read (and the SaveContext is null fallback that masks the bug into a wrong-behavior path if the lifetime is botched).
  4. Data.Postgres/ServiceCollectionExtensions.cs — the existing Add* convention to mirror.
  5. Web/Web.csproj — package id Cerebellum.BlazorBlocks.Web, current version, where to add the new ServiceCollectionExtensions.cs.
  6. pack.ps1 — the pack/push flow for the version bump.

AuthBlocks (C:\Development\AuthBlocks):

  1. AuthBlocksWeb/Startup.csConfigureAuthServices, the single entry point; add the AddBlazorBlocksWeb() call here.
  2. AuthBlocksWeb/Components/Pages/UserAdmin/Users/Users.razor and .../UserAdmin/Registrations/Registrations.razor — the two pages that render <ModelView> and trigger the defect.
  3. The AuthBlocks pack.ps1 / packaging script — to bump AuthBlocks Web after it references the new BlazorBlocks version.