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

345 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
- **SkipperHaven** — `C:\Development\skipper\SkipperHaven\SkipperHaven\Program.cs:129`:
```csharp
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:
```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 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.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<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.
---
## 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
{
/// <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()`:
```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<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.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 `<ModelView>` and trigger
the defect.
3. The AuthBlocks `pack.ps1` / packaging script — to bump AuthBlocks Web after it references the new
BlazorBlocks version.