diff --git a/product-notes/blazorblocks-modelview-di-registration-brief.md b/product-notes/blazorblocks-modelview-di-registration-brief.md new file mode 100644 index 0000000..52dfa30 --- /dev/null +++ b/product-notes/blazorblocks-modelview-di-registration-brief.md @@ -0,0 +1,344 @@ +# 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 ``: + +- `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: + +- **SkipperHaven** — `C:\Development\skipper\SkipperHaven\SkipperHaven\Program.cs:129`: + ```csharp + builderServices.AddScoped(); + ``` + (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: + +```csharp +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 137–172). +- `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.cs` → `AddBlazorBlocksPostgres()` (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` | `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. + +--- + +## 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. + +```csharp +// C:\Development\BlazorBlocks\Web\ServiceCollectionExtensions.cs (new) +using Microsoft.Extensions.DependencyInjection; +using Web.Maintenance.Entities; + +namespace Web; + +public static class ServiceCollectionExtensions +{ + /// + /// 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. + /// + public static IServiceCollection AddBlazorBlocksWeb(this IServiceCollection services) + { + // Per-circuit slot bridging ModelView's save callback into EditModelModal. + services.AddScoped(); + 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()`: + +```csharp +// 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 state** — `ModelView.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()`, 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();`) 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` (137–172) 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.cs` — `ConfigureAuthServices`, 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 `` and trigger + the defect. +3. The AuthBlocks `pack.ps1` / packaging script — to bump AuthBlocks Web after it references the new + BlazorBlocks version.