Skip to content

Reactor core has hard type references to Charting (and Docking) — violates control-family isolation, leaks ~7.8 KB into AOT retail #498

@codemonkeychris

Description

@codemonkeychris

Summary

The Reactor core (src/Reactor/Core/ and src/Reactor/Hosting/) has hard type and method references to the Charting subsystem (and, more narrowly, to Docking). This violates the "core should know nothing about specific control families" boundary and has measurable downstream effects:

  • Trim leak. Apps that never render a chart still pay for D3Color, ForcedColorsTheme, and the D3Charts cctor cascade in their AOT-published binary (~7.6 KB measured in samples/apps/hello-world-aot, larger if the accessibility scanner runs). This is not subsumed by Devtools subsystem leaks ~1.5 MB into AOT retail publishes (10% of EXE) — needs FeatureGuard or package split #497 — it leaks regardless of whether devtools is trimmed out.
  • Architectural rot. New control families (charting today, docking partly, the next one tomorrow) need backdoor entry points into the core because we've established that "well, charting got one." Each one extends the surface area that the core has to keep stable.
  • Cold-start cost. Even when the runtime-deferral patterns work (the PushChartingState pattern in ReactorHost cleverly defers the cctor at runtime — but not the trim), there's a non-trivial maintenance cost in carrying those workarounds.

This issue tracks inverting these couplings so the core has zero references to any specific control family.

Enumerated coupling sites (current state)

Charting → core

  1. src/Reactor/Hosting/ReactorHost.cs:67private Charting.ForcedColorsTheme? _forcedColorsTheme;
    Field typed on a Charting type. Forces the trimmer to retain ForcedColorsTheme metadata even when no chart is ever mounted.

  2. src/Reactor/Hosting/ReactorHost.cs:430-434PushChartingState():

    Charting.D3Charts.IsForcedColors = _isForcedColors;
    Charting.D3Charts.IsReducedMotion = _isReducedMotion;
    Charting.D3Charts.ForcedColors = _forcedColorsTheme;

    Three static setter calls on D3Charts. Reachable from RenderLoop. Forces D3Charts type + cctor; cctor reads D3Color.Category10 → forces D3Color (6.4 KB). The comment at line 426 notes the runtime deferral works but doesn't help the trimmer.

  3. src/Reactor/Hosting/ReactorHost.cs:409,923 — direct calls to Charting.ForcedColorsTheme.FromSystem() from InitChartingState and the accessibility-settings change handler.

  4. src/Reactor/Hosting/ChartingActivation.cs — exists solely as a chart-specific entry point into the host. Smaller concern (the file itself is tiny) but it's an example of the core defining a one-off contract for one subsystem.

  5. src/Reactor/Core/Element.cs:2900,2913CanvasElement has:

    internal Charting.Accessibility.IChartAccessibilityData? ChartData { get; init; }
    internal Charting.Accessibility.ChartPalette? CustomPalette { get; init; }

    A general-purpose CanvasElement carries chart-specific data slots. Forces both Charting types to be reachable whenever CanvasElement is reachable.

  6. src/Reactor/Core/AccessibilityScanner.cs:451-768 — extensive chart-specific scanner logic (CheckChartTitle, CheckChartDescription, Charting.Accessibility.ChartPalette.ContrastRatio/Harden/MinColorblindDeltaE, ChartSummarizer.Summarize, hardcoded new Charting.D3.D3Color(...) light/dark backgrounds). The core accessibility scanner has chart accessibility logic baked in.

  7. src/Reactor/Core/V1Protocol/Descriptor/Descriptors/PathDescriptor.cs:188

    c.Data = Microsoft.UI.Reactor.Charting.PathDataParser.Parse(pdsFallback);

    The Path control's descriptor reaches into Charting for SVG path-data parsing. PathDataParser is conceptually general-purpose but it lives in the Charting namespace and creates a core-to-Charting coupling.

Docking → core

  1. src/Reactor/Core/Element.cs:845-851Element record equality is special-cased for Docking.Native.DockSplitterElement and Docking.Native.DockDropTargetOverlayElement:
    (Microsoft.UI.Reactor.Docking.Native.DockSplitterElement da,
     Microsoft.UI.Reactor.Docking.Native.DockSplitterElement db) =>
        da.Direction == db.Direction,
    (Microsoft.UI.Reactor.Docking.Native.DockDropTargetOverlayElement oa,
     Microsoft.UI.Reactor.Docking.Native.DockDropTargetOverlayElement ob) =>
        oa.Mode == ob.Mode,
    Same family — a docking-specific equality override in the core Element type.

Measured impact

Against the same samples/apps/hello-world-aot repro used in #497:

Reachable in retail (no charts, no devtools) Bytes Reason
Microsoft.UI.Reactor.Charting.D3.D3Color 6,583 D3Charts cctor reads D3Color.Category10
Microsoft.UI.Reactor.Charting.ForcedColorsTheme 1,027 ReactorHost._forcedColorsTheme field type
Microsoft.UI.Reactor.Charting.D3Charts 156 PushChartingState static setter calls
Charting direct total ~7.8 KB

The Charting.Accessibility.* namespace (ChartPalette, ChartSummarizer, etc.) does not appear in hello-world's mstat because nothing reachable invokes AccessibilityScanner's chart-checking arms. The moment an app turns on a11y scanning or instantiates a CanvasElement whose ChartData/CustomPalette slot is populated, the chain extends significantly (palette hardening, color-blind delta-E math, contrast ratio calculation).

Suggested fix shape

The general pattern is indirection at the seam — the core defines a minimal interface and Charting (or Docking) registers an implementation at startup. The interface lives in core; the implementation lives in the subsystem; trimmer sees no static reference to the concrete type from the core.

For Charting

// src/Reactor/Core/Internal/IChartingHostBridge.cs   (new)
internal interface IChartingHostBridge
{
    void PushAccessibilityState(bool isForcedColors, bool isReducedMotion, object? forcedColorsThemePayload);
}

// src/Reactor/Hosting/ReactorHost.cs
private static IChartingHostBridge? s_chartingBridge;   // set by Charting at startup
private object? _forcedColorsTheme;                       // becomes object?

private void PushChartingState()
    => s_chartingBridge?.PushAccessibilityState(_isForcedColors, _isReducedMotion, _forcedColorsTheme);

// src/Reactor/Charting/D3ChartsHostBridge.cs   (new, lives in Charting)
internal sealed class D3ChartsHostBridge : IChartingHostBridge { ... }
// Registered once from ChartingActivation.RequestActivation

Similarly:

  • CanvasElement.ChartData/CustomPalette move into a separate ChartCanvasElement : CanvasElement (or attach via the existing Attached dictionary, which is already how the scanner reads ChartScannerHint).
  • AccessibilityScanner chart-checking logic moves out to Charting.Accessibility.ChartAccessibilityChecker and registers as an IScannerExtension.
  • PathDataParser either moves into core (it's general SVG-path parsing) or the PathDescriptor calls through a delegate.

For Docking

Element equality special-cases move into the Docking subsystem itself by overriding Equals/GetHashCode directly on DockSplitterElement/DockDropTargetOverlayElement (they're records — they can author their own equality without the core knowing about them).

Reproduction setup (identical to #497)

Use samples/apps/hello-world-aot on branch samples/hello-world-aot. Publish:

dotnet publish samples\apps\hello-world-aot\HelloWorldAot.csproj -p:Platform=x64 -r win-x64 -c Release

Examine obj\x64\Release\net10.0-windows10.0.22621.0\win-x64\native\HelloWorldAot.mstat in sizoscope. Search for Charting and Docking namespaces. Today: 3 charting types reachable (~7.8 KB). After the fix: zero charting types should be reachable in a chart-free app.

(The csproj already has IlcGenerateMstatFile=true, IlcGenerateDgmlFile=true, PublishAot=true, StackTraceSupport=false, InvariantGlobalization=true, WindowsAppSDKSelfContained=false, ML-transitive exclusions, and the WindowsAppSDK#6394 .pri/.xbf copy target. See HelloWorldAot.csproj for the full property list.)

Validation checklist

After the indirection fix lands, the hello-world-aot mstat must show:

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions