A Blazor component cannot @inject a hub-scoped service
Symptom. Every portal in the fleet answered HTTP 200, every asset loaded, the Blazor circuit
opened — and the home page (/) was white for every signed-in visitor. The only trace was one
line in the browser console, written by the server as it tore the circuit down:
System.InvalidOperationException: Cannot provide a value for property 'Workspace' on type
'Memex.Portal.Shared.Pages.Index'. There is no registered service of type 'MeshWeaver.Data.IWorkspace'.
/Store, /Doc, chat and every other route rendered. Nothing was logged by the portal at a level
that ships, /health stayed Degraded for its usual reasons, and the prod synthetic probe — which
only ever GETs — kept reading 200.
Cause. Pages/Index.razor carried @inject IWorkspace Workspace. IWorkspace is registered
by AddData() on the hub's own service collection (MessageHubConfiguration.WithServices,
core DataExtensions): a hub's provider is a child lifetime scope of the host container
(ServiceProviderExtensions.SetupModules(parent) → BeginLifetimeScope), so anything registered
there is visible from that hub and its descendants and from nothing above it. A Blazor circuit
scope is a child of the host container, not of the root hub — so IWorkspace is, by
construction, never resolvable through @inject / [Inject]. The same is true of every service a
hub registers on itself. IMessageHub is host-registered, but MeshBuilder.AddSingleton(BuildHub)
supplies the process-lifetime root mesh hub. Circuit components use the scoped PortalApplication
registered by AddBlazor(), as Onboarding and BlazorView do:
@using MeshWeaver.Data
@using MeshWeaver.Hosting.AspNetCore.Portal
@inject PortalApplication PortalApplication
@code {
private IWorkspace Workspace => PortalApplication.Hub.GetWorkspace();
}
The distinction also preserves identity: PortalApplication keys its hub by the circuit id and
stamps the circuit accessor's current user on outbound posts when ambient context is absent.
Deriving a workspace from the root hub removes the activation exception but does not provide that
circuit identity boundary for reactive callbacks and hub work.
This is the same rule the core pages state from the other side — "IWorkspace is AddScoped,
so it belongs to one hub; deliberately do NOT resolve it from a provider you did not get from that
hub" (Doc/Architecture/AReferenceThatCannotBeAKey, RouterTrafficDetection) — met at the one
place a Razor page can hide a resolve: the @inject directive, which reads as a declaration and is
a GetRequiredService on the circuit scope at activation.
Why nothing caught it. The page's own rendering test built its provider by hand and registered
mesh.GetWorkspace() as a singleton — so the test offered the page a service the real host never
offers, and passed. A test fake must be scoped to what the subject can actually reach (the same
lesson as Hosting/ATestFakeIsScopedToItsSubject). The end-to-end lane signs in through the
Plugin Catalog page, not through /. The prod probe never opens a circuit. So the first thing that
resolved Index from a real circuit was a visitor.
Why it surfaced as "everything is down" on one morning. The injection merged with the first-run-setup feature (plugins#1961), and the first portal images to carry it rolled onto both public instances the next morning, in the same hours in which the Continuous update policy rolled or restarted the public instance seven times behind a wave of merges. A white home page on every portal, a circuit that dies every half hour, and a local MCP endpoint whose token store had been wiped by an eviction all arrived together; only the first was a code defect.
The fix, and the guard. Index injects the existing scoped PortalApplication and derives the
workspace from its circuit hub. The rendering test uses the real AddBlazor() registrations and
a scoped circuit accessor, and asserts that the host does not offer IWorkspace. It proves both
signed-in Activity rendering and the anonymous landing title, including live renames.
NoComponentInjectsAHubScopedServiceTest (Memex.Portal.Gui.Test) scans every IComponent in
the portal GUI and in every MeshWeaver.Blazor* assembly it references, and fails naming each
[Inject] property whose type the root hub's provider can serve and the host container cannot
(IServiceProviderIsService on both). The oracle is measured, not listed: a host-registered type is
fine, a framework type neither side knows (NavigationManager, IJSRuntime, the portal's own
scoped services registered by the web host) is outside the invariant, and the test also asserts
that its oracle still reads IWorkspace as hub-scoped, so it cannot pass vacuously if the
registration model changes underneath it. On the unfixed page it fails naming
Memex.Portal.Shared.Pages.Index.Workspace injects IWorkspace.
If you see the symptom again. A white page with a healthy /health and a console line of the
shape "Cannot provide a value for property … no registered service of type …" is this class, and
the property named tells you the component. Do not register the service on the host as a
forwarder into the root hub's scope: the hub a circuit should talk to is decided by the page
(PortalApplication, a node's own hub), and a host-level forwarder would silently pin every
component to the root hub's workspace. Use the existing owner of the correct hub and derive the
service from that hub; for circuit-owned data, use PortalApplication.Hub.