Skip to content

Eliminate string-name view component invocation, including section UI fanouts #1815

Description

@peterdrier

Section: TBD (cross-cutting: Base contribution interfaces, Shell, Settings, Users, Onboarding, Governance, Camps, Tickets, Teams, Debug)

Context

A section can't reference another section's project, so cross-section UI is rendered by string name. Two forms:

  1. Direct calls: Component.InvokeAsync("Name", new { ... }) in views (48 call sites).
  2. Fanouts: contribution records carrying string ComponentName, rendered by a host with Component.InvokeAsync(x.ComponentName, ...):
    • ChromeComponent via ISectionChrome / ISectionMemberDashboard → src/Humans.Web/Views/Shared/Components/ChromeSlot/Default.cshtml
    • SettingsTab via ISectionSettings → src/Sections/Humans.Settings/Views/Shared/Components/SettingsTabs/Default.cshtml
    • UserPart via IUserPart → src/Sections/Humans.Users/Views/Shared/Components/UserParts/Default.cshtml (passes new { userId })

In both forms the name and the anonymous-object arguments are bound at runtime. Nothing is compile-checked.

Problem

#1756 changed Onboarding's event DTO. /OnboardingWidget/Shifts then threw InvalidCastException (EventSettingsInfo → BurnSettingsInfo) because Shifts' OnboardingShiftsList component still took the old type. The build stayed green, and the only covering test (OnboardingPageRenderTests) is local-only. The fix was #1810. Any renamed component, renamed parameter or changed argument type fails the same way, and a typo in a fanout ComponentName fails the same way too.

A typed wrapper around the string call (e.g. a .Contracts extension that calls InvokeAsync("Name", ...) inside) was considered and rejected: it hides the smell rather than removing it.

Worked example: /Settings tabs

Today.

The seam, in src/Sections/Humans.Settings.Contracts/ISectionSettings.cs:

public sealed record SettingsTab(string Key, string Label, string ComponentName, string? Policy = null, int Weight = 0);
public interface ISectionSettings : ISectionContribution { IEnumerable<SettingsTab> Tabs(); }

The contributors are internal sealed class SectionSettings : ISectionSettings at a section's root. Nine sections have one. Each references only Humans.Settings.Contracts, never the Settings section:

  • src/Sections/Humans.Shifts/SectionSettings.cs: new SettingsTab("shifts", "Settings_TabShifts", "ShiftsSettingsTab", PolicyNames.AdminOnly)
  • src/Sections/Humans.Camps/SectionSettings.cs: new SettingsTab("barrios", "Settings_TabBarrios", "CampBarriosSettingsTab", PolicyNames.CampAdminOrAdmin)

Discovery happens at Shell startup, in src/Humans.Web/Extensions/SectionDiscoveryExtensions.cs:

  1. SectionAssemblies() walks DependencyContext.Default.RuntimeLibraries, keeps the assemblies that declare an ISection entry point, and filters them to active sections.
  2. DiscoverImplementations<ISectionContribution>() reflects over each assembly's GetTypes(), including internal types. It creates each concrete marker implementation with Activator.CreateInstance.
  3. RegisterContributions registers each instance as a singleton against every seam interface it implements (services.AddSingleton(typeof(ISectionSettings), instance)).

Rendering:

  • SettingsTabsViewComponent injects IEnumerable<ISectionSettings>, and SettingsTabComposition merges the tabs and applies each tab's policy.
  • SettingsTabs/Default.cshtml renders each tab with @await Component.InvokeAsync(tab.ComponentName).
  • MVC resolves the string against its view component registry. SectionViewComponentFeatureProvider adds each section's internal components to that registry, so "ShiftsSettingsTab" resolves to ShiftsSettingsTabViewComponent.

A typo or a rename only fails at request time on /Settings.

Proposed.

  • The record becomes SettingsTab(string Key, string Label, Type Component, ...).
  • Shifts writes new SettingsTab("shifts", ..., typeof(ShiftsSettingsTabViewComponent), ...). That compiles inside Shifts because the class is Shifts' own, internal or not.
  • The view calls Component.InvokeAsync(tab.Component), which is MVC's Type overload.
  • Settings still never references Shifts; it only receives a System.Type through DI. The dependency arrows stay child → contract ← host, and a rename becomes a compile error inside the owning section.

Proposed Solution

Sketch only; needs design:

  • Fanouts carry a Type, not a string (as in the example above). The contributor lives in the owning section, so typeof(...) needs no cross-section reference. The host renders with Component.InvokeAsync(Type, ...).
  • Each slot defines its invocation contract (no args for chrome and settings tabs, Guid userId for UserPart). An architecture test reflects over every contribution and asserts that the component's Invoke/InvokeAsync signature matches its slot's contract, so argument drift fails CI.
  • Direct cross-section calls become contributions. The host section defines a slot and the owning section contributes its component by Type (e.g. Onboarding's shifts step exposes a slot and Shifts fills it). Same-section calls use InvokeAsync<T> / typeof, or <vc:...> tag helpers.
  • Ban the string overload. Add an analyzer, or an arch-test ratchet as a stopgap, that rejects Component.InvokeAsync(string, ...) in any view.

Acceptance Criteria

  • ChromeComponent, SettingsTab and UserPart identify their component by Type; no string ComponentName remains on any contribution record
  • An arch test fails when a contributed component's invoke signature doesn't match its slot's contract
  • Zero Component.InvokeAsync("...") string-name calls remain in src/
  • An analyzer blocks new string-name invocations
  • design-rules.md §15 step 6 describes the replacement pattern

Key files: src/Humans.Base/Interfaces/ISectionChrome.cs, src/Humans.Base/Interfaces/IUserPart.cs, src/Sections/Humans.Settings.Contracts/ISectionSettings.cs, src/Humans.Web/ViewComponents/ChromeSlotViewComponent.cs and its view, SettingsTabs and UserParts views, every Section*.cs contributor, src/Sections/Humans.*/Views/**/*.cshtml, docs/architecture/design-rules.md

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    blocked:needs-designCannot be implemented until design is resolved (UX, architecture, API shape, etc.)db:noNo migration neededtech-debt

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions