Skip to content

Commit 40f6ca4

Browse files
committed
feat: trimmable support WIP
1 parent def2f2c commit 40f6ca4

100 files changed

Lines changed: 814 additions & 182 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Directory.Packages.props

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
<PackageVersion Include="Microsoft.AspNetCore.Components.Web" Version="8.0.0" Condition="'$(TargetFramework)' == 'net8.0'" />
1111
<PackageVersion Include="Microsoft.AspNetCore.Components.Web" Version="9.0.0" Condition="'$(TargetFramework)' == 'net9.0'" />
1212
<PackageVersion Include="Microsoft.AspNetCore.Components.Web" Version="10.0.0" Condition="'$(TargetFramework)' == 'net10.0'" />
13+
<!-- Publish smoke app (trimmed WASM publish of the library) -->
14+
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.0" />
1315
</ItemGroup>
1416

1517
<ItemGroup>

PLAN-TRIMMING-FOLLOWUPS.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Trimming follow-ups
2+
3+
Findings from the IsTrimmable change set (see PR-DESCRIPTION.md) that were deliberately left out of scope. Ordered roughly by value.
4+
5+
## 1. Native AOT compatibility (`IsAotCompatible`)
6+
7+
The big one. Blocked on `RequiresDynamicCode` surfaces:
8+
9+
- `JsonDataSourceSchema.cs:760-855` — six `Expression.Lambda(...).Compile()` getter builders (property/field/dictionary). Need interpreter-fallback awareness or a rewrite to delegate-free access; the untyped `PropertyGetters` (`Func<object,object>` via reflection) could serve as the AOT path.
10+
- `RuntimeHelper.cs:57-102``MakeGenericMethod` + compiled expressions (net8-only, dead on net9+; consider `#if`-ing the whole probe out once net8 support drops).
11+
- `BaseRendererControl.cs:~742``typeof(DynamicContentInfo<>).MakeGenericType(templateContentType)` + compiled lambda; `templateContentType` comes from a closed set of `typeof` literals, so a generated switch could replace it.
12+
- The PublishSmoke app is named publish-scoped on purpose: add a wasm AOT publish profile (`RunAOTCompilation=true`) there when this work starts.
13+
14+
## 2. PublishSmoke coverage gaps
15+
16+
- Targets net10 only — the net8-only code (`EventCallbackExtensions` reflection path, `RuntimeHelper` InvokeUnmarshalled probe) is covered by the build-time analyzer but never by an actual ILLink pass. Consider a net8 publish leg.
17+
- ILLink only analyzes marked code: components the smoke app doesn't reference are invisible to it. Consider growing the app toward representative coverage (templating/dynamic content, a `Type[]`-based module registration to exercise the documented failure mode) and/or wiring it into the Playwright integration-test infrastructure so the browser checks run in CI instead of by hand.
18+
19+
## 3. Docs cleanup: stale "generated code" claims
20+
21+
`src/components/Blazor/` is hand-maintained (the `npm run ingest` codegen was dropped), but these still claim otherwise and mislead exploration/tooling: the csproj TODO comment (`src/IgniteUI.Blazor.Lite.csproj:5-6`), `FORMATTING.md` ("re-formatted after every ingest"), `Child-Modules-PR-description.md` ("the emitter needs the same change"), `PR-tabs-collection-fix.md`, `skills/igniteui-blazor-lite-testing/SKILL.md:55`.
22+
23+
## 4. Module registration: analysis results & optional enhancement
24+
25+
Empirical findings (2026-08-20, via PublishSmoke experiments):
26+
27+
- All 75 `Register` bodies are string-only (`ModuleLoader.Load(runtime, "WebXModule")``RequestLoad(string)`) — no component-type references. Rooting them all costs ~nothing.
28+
- `typeof(IgbXModule)` in a trimmed app keeps the type but NOT `Register` (member-level sweep); the legacy `Type[]` preload then silently no-ops. Only bites modules whose component is never statically used — every component roots its own module's `Register` via `EnsureModulesLoaded`.
29+
- **Type-level `[DynamicallyAccessedMembers]` does NOT fix it** — tested on both the `IIgbModule` interface and directly on the module class: type-level DAM is *flow-dependent* (acts only where an annotated `Type` value reaches a recognized reflection sink), and the `typeof` dies in the unannotated `Type[]`. A conditional "keep Register iff module kept" is not expressible for this API shape.
30+
- Using the `IgbModuleCollection` overload anywhere in the app keeps `Register` on ALL kept module types (constrained `IIgbModule.Register` call → interface implementations preserved) — the smoke app relies on this for its dual-path check.
31+
32+
**RESOLVED (2026-08-20)** by `IgbModuleRef`: the `params Type[]` overloads were replaced with `params IgbModuleRef[]` + an implicit conversion from `Type` whose `[DAM(PublicMethods)]` parameter is traced at each call site — source-compatible for `typeof(...)` args, conditional preservation, verified in isolation (True; raw-`Type[]` control False). Key mechanism: annotations on a struct's **member** ride through collections/arrays (location-based), unlike parameter-flow tracing which dies at any collection hop. The registry idea is superseded. Remaining relevant: the legacy `WithModulesToLoad` settings path keeps its suppression; a module listed in both paths registers once per path (client loading dedupes by name).
33+
34+
**Hard-won constraint for future annotation work:** never put `[DynamicallyAccessedMembers]` on *method parameters or fields* of `ComponentBase`-derived classes — `OpenComponent<T>`'s `DAM(All)` roots component members "via reflection" and ILLink then emits IL2111/IL2110 into every consuming app. Framework precedent (e.g. `RouteView.DefaultLayout`) allows annotated *properties* only. This is why `ObjectToParam`/`TryGetWCEnumName` use contained suppressions instead of the annotation chain.
35+
36+
## 5. Trim-suppression mechanics (verified 2026-08-20)
37+
38+
- Blazor WASM publish sets `SuppressTrimAnalysisWarnings=true` by default. Unmuzzling it in PublishSmoke surfaced real library issues once (RuntimeHelper IL2026, App-side IL2111 — both fixed), but the **framework's own assemblies** (`DotNetDispatcher`, `ComponentFactory`, …) fail the analysis under WASM, so the gate reverted to the default; the library's analysis cleanliness is enforced at build time by `WarningsAsErrors` instead. Periodically re-try unmuzzling after SDK updates — it finds things nothing else does.
39+
- Empirically, this ILLink/Blazor version did **not** resurface library-internal dataflow warnings at publish even when unmuzzled — `#pragma warning disable IL2075` and `[UnconditionalSuppressMessage]` are currently equivalent for both in-repo gates. Prefer the attribute anyway (now policy, see the csproj comment): it persists in metadata for other trim tooling (NativeAOT's ILCompiler for the planned AOT work, other SDK pipelines); pragmas vanish at compile time.
40+
- SDK 10.0.300 bug: consuming WASM publishes double-discover the library's `lib.module.js` JS initializer via the static-web-assets cross-project protocol (`ApplyCompressionNegotiation` duplicate-key crash, or "Conflicting assets" with compression off). Workaround: the library csproj exposes `-p:IgbExcludeJsInitializer=true` (used by the smoke publish, which loads `app.bundle.js` manually). Re-test on SDK updates and drop the workaround when fixed.
41+
- For line-precision suppression (attributes can't target a line): extract the offending lines into a small private helper and put `[UnconditionalSuppressMessage]` on the helper. Caution: give the helper an *unannotated* `Type` parameter — annotating it just moves the warning to the call site (verified: the requirement re-materializes at the collection read, `IEnumerator<T>.Current`).
42+
43+
## 6. Small items
44+
45+
- **`BuildSequenceInfo` attribute matching** (`BaseRendererControl.cs:~411-427, ~436-450`): matches attributes by `GetType().Name` strings; typed `is` patterns would be safer (the code hard-casts right after anyway). Reverted from the trimming PR as an unrelated cleanup — do it in its own change.
46+
- **`AddIgniteUIBlazor(null)`** now hits CS0121 ambiguity between the `Type[]` and `Action<IgbModuleCollection>` overloads. Decide whether to care (probably not; document if anyone reports it).
47+
- **TRIMMING.md phrasing**: "your data item types" doesn't explicitly cover *library* types bound as data (e.g. `List<IgbChatMessage>` on `IgbChat.Data`). Works in practice (their `SerializeCore` statically references every property), but could be stated.
48+
- **`GatherSimpleAttributes` MaxDepth**: now honors `Settings.JsonSerializerOptions.MaxDepth` (default 32) instead of the framework default 64. If a direct-render description ever legitimately nests deeper, bump the library default rather than special-casing the call site.
49+
- **Trimmed self-contained Blazor Server** (unsupported by the platform) could strip `RemoteJSRuntime.IsInitialized` and break prerender detection (`IsRuntimeValid` suppression documents this). No action unless MS makes server trimming supported.
50+
51+
## Closed by the event-callback branch
52+
53+
- The old `CompareEventCallbacks` hash-equality short-circuit (identity-hash collision could report different callbacks equal) and the misleading "fixed in .net 9" comment — both gone with `EventCallbackExtensions.EqualsCompat`, which is also trim-clean with no annotations. Reference: only .NET 10 gave `EventCallback.Equals` delegate value equality; net9's override is `ReferenceEquals`-based (verified empirically against 9.0.0/9.0.19 and 10.0.8).

PR-DESCRIPTION.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# feat: make IgniteUI.Blazor.Lite trim-compatible
2+
3+
## What & why
4+
5+
Enabling [`<IsTrimmable>true</IsTrimmable>`](https://learn.microsoft.com/en-us/dotnet/core/deploying/trimming/prepare-libraries-for-trimming) surfaced 48 unique trim-analysis warnings per TFM (net8/net9/net10), concentrated in the JSON interop layer and the reflective data-source/module plumbing. This PR resolves all of them — real fixes where possible, narrowly-justified suppressions only where the reflection is over types the app supplies at runtime — so the library is safe to consume from apps published with [trimming](https://learn.microsoft.com/en-us/aspnet/core/blazor/host-and-deploy/configure-trimmer) (the Blazor WebAssembly publish default).
6+
7+
Based on `dpetev/event-callback-compare-fix`: its `EventCallbackExtensions.EqualsCompat` replaces the old reflective `CompareEventCallbacks` and is already trim-clean under the new analyzer gate with no annotations (`typeof(EventCallback<TValue>).GetField` is statically analyzable).
8+
9+
## Changes
10+
11+
### Real fixes
12+
13+
- **JSON source generation** ([docs](https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/source-generation)): new `IgbJsonContext` (`src/componentsBase/IgbJsonContext.cs`) covers the closed set of wire shapes (`Dictionary<string,object>`, its array, `object`, `object[]`, `string`, `string[]`, `int[]`, `double[]`); all ~25 `JsonSerializer` call sites in `BaseRendererControl` now use typed `JsonTypeInfo` overloads — the IL2026s are gone for real, not suppressed. Deserialized `object` values still surface as `JsonElement`, identical to the reflection-based behavior.
14+
- **`DateRangePicker` Change handler**: the `Serialize(args.Detail) → Deserialize<IgbDateRangeValue>` round-trip (with a per-event `JsonSerializerOptions` allocation) is replaced by a direct `Start`/`End` copy — the two types share the exact shape.
15+
- **Trim-safe module registration**: new `IIgbModule` interface (`static abstract Register`) implemented by all 75 `*Module` classes, plus two registration shapes that survive trimming: the fluent `AddIgniteUIBlazor(m => m.Add<IgbTreeModule>())` collection (zero reflection), and — replacing the `params Type[]` overloads — `params IgbModuleRef[]` with an implicit conversion from `Type`, so existing `AddIgniteUIBlazor(typeof(IgbTreeModule))` call sites compile unchanged while the conversion's `[DynamicallyAccessedMembers]` parameter makes the trimmer preserve each module's `Register` per call site (raw `typeof` in a `Type[]` roots the type but **not** the method — verified empirically, the preload silently no-ops). `IgbModuleRef` validates the type implements `IIgbModule`. The legacy `WithModulesToLoad` settings path remains reflective for back-compat (suppressed + documented).
16+
17+
### Annotations ([DynamicallyAccessedMembers](https://learn.microsoft.com/en-us/dotnet/core/deploying/trimming/fixing-warnings))
18+
19+
- `BaseRendererControl` class: `PublicProperties` — inherited by every component, satisfies the `BuildSequenceInfo` property walk; costs nothing real since rendered components already get `All` via `OpenComponent<T>`.
20+
- `Utils.TryGetWCEnumName` / `ObjectToParam(…, Type, …)` chain: `PublicFields` (all callers pass `typeof` literals).
21+
- `IgbComponentRendererContainer.ComponentType` (+ backing field) and `DynamicContentInfo.ControlType` / `TypedDynamicContent(Type)`: `All`, as required by `RenderTreeBuilder.OpenComponent(Type)`.
22+
- `UnmarshalledDataSource.GetIListTypeArg`/`GetIEnumerableTypeArg`: `Interfaces`.
23+
24+
### Suppression inventory (each `[UnconditionalSuppressMessage]`, with justification)
25+
26+
| Site | Code | Why it is safe |
27+
|---|---|---|
28+
| `BaseRendererControl.InvokeSendMessageSync` | IL2026 | Arguments are strings, `DotNetObjectReference`, `ElementReference[]`; the return is only consumed as opaque `JsonElement` — no user types cross the JS interop boundary. |
29+
| `BaseRendererControl.GetWCEnumTransform` (helper extracted from `BuildSequenceInfo`) | IL2070 | ILLink preserves all fields of kept enum types; enum parameter property types are kept with their declaring component. Validated in the trimmed browser smoke (enum values render as camelCase strings, not numbers). |
30+
| `RendererSerializer.AddEnumProp` | IL2075 | Same enum-field preservation. |
31+
| `Utils.TryGetWCEnumName` | IL2070 | Same enum-field preservation. Deliberately suppressed instead of annotated: DAM-annotated *method parameters* on component base classes trigger IL2111 in every consuming app, because `OpenComponent<T>` roots component members "via reflection". |
32+
| `IgniteUIBlazor` ctor (legacy settings `Register` loop) | IL2075 | Reflection over the legacy `WithModulesToLoad` Type collection; the `IgbModuleRef`/collection call shapes are the trim-safe paths — documented in TRIMMING.md. |
33+
| `IgniteUIBlazor.IsRuntimeValid` | IL2075 | Probes optional `RemoteJSRuntime.IsInitialized`; in supported trim scenarios (WASM, MAUI BlazorWebView) the type doesn't exist and the probe correctly finds nothing. A string `DynamicDependency` can't be used (IL2035 when the Server assembly is absent). |
34+
| `RuntimeHelper` ctor | IL2075, IL2060, IL2026 | net8-only `InvokeUnmarshalled` probe (API removed from the framework in net9+), preserved via the existing `DynamicDependency`; absence degrades to the raw-pointer `InvokeVoid` path that is the only path on net9+. IL2026: `GetMethods()` reflection-marks the runtime's RUC members (net10 `GetValue`/`SetValue`/…), which the probe never invokes. |
35+
| `IgbComponentRendererContainer.ComponentType` | IL2078 | The backing field is only assigned through the annotated property; annotating the field itself would expose IL2110 to consuming apps. |
36+
| `JSDataSourceSchema.GetPropertiesFromType`/`GetFieldsFromType` | IL2067 | Data-source boundary: item types are supplied by the app at runtime — documented requirement in TRIMMING.md. |
37+
| `JSDataSourceSchema.CreateFromDictionary` | IL2075 | Same boundary. |
38+
| `UnmarshalledDataSource.ExtractSchema` / `ExtractSchemaFromType` | IL2072 / IL2067 | Same boundary. |
39+
40+
### Guards & docs
41+
42+
- Library csproj: the full IL2xxx trim-analyzer code range is now `WarningsAsErrors` — newly-added trim-unsafe code fails the build.
43+
- New **`TRIMMING.md`** (linked from README): consumer guidance — preserving data item types (including nested complex types), the trim-safe module registration overload, AOT status.
44+
- New **`tests/IgniteUI.Blazor.Lite.PublishSmoke`**: Blazor WASM app publishing the library trimmed with `TrimmerSingleWarn=false` + `ILLinkTreatWarningsAsErrors=true`; named publish-scoped so the future AOT pass reuses it.
45+
46+
## Verification
47+
48+
- `dotnet build` (all 3 TFMs, rebuild): **0 IL warnings** with the warnings-as-errors gate active.
49+
- `dotnet test`: 958 passed / 0 failed on each of net8.0, net9.0, net10.0.
50+
- `dotnet publish` of PublishSmoke: **0 ILLink warnings** — with `SuppressTrimAnalysisWarnings=false` (Blazor WASM suppresses linker analysis warnings by default; the smoke app opts back in), single-warn off, warnings-as-errors on.
51+
- Headless-browser smoke against the trimmed publish output: enum attributes render as camelCase strings (`variant="outlined"`, `shape="circle"` — proves enum fields survive trimming), the combo binds a 3-item reflected POCO list preserved per the TRIMMING.md pattern, and a dispatched `igcChange` round-trips through the new date-range copy path into rendered output.
52+
- Module-registration semantics verified empirically with isolated trimmed publishes of an app whose preloaded module's component is never statically used (`IgbChatModule`): a raw `Type[]` preload silently loses `Register` (**False**); the same call shape through `IgbModuleRef`'s annotated implicit conversion preserves it (**True**) — the annotation rides the struct's annotated property through collections, unlike type-level `[DynamicallyAccessedMembers]` (on the interface or the module class), which is flow-dependent and does *not* fix a `typeof` dying in an unannotated `Type[]` (also verified). The smoke app permanently exercises `Add<T>`, `Add(typeof(...))`, and the settings overload.
53+
- Smoke publish uses `-p:IgbExcludeJsInitializer=true`, a property-gated workaround for an SDK 10.0.300 static-web-assets bug that double-discovers the library's JS initializer in consuming WASM publishes (see the csproj comment).
54+
55+
## Behavioral notes
56+
57+
- `GatherSimpleAttributes` previously deserialized with no options (default `MaxDepth` 64); it now honors `Settings.JsonSerializerOptions.MaxDepth` (library default 32) like its sibling call sites.
58+
- **Breaking (module overloads)**: replacing `params Type[]` with `params IgbModuleRef[]` is binary-breaking; source stays compatible for expanded `typeof(...)` arguments, but a pre-built `Type[]` array no longer compiles (arrays don't apply element conversions — use `IgbModuleRef[]` or expanded args), and non-`IIgbModule` types now throw `ArgumentException` at registration instead of being silently probed. A module listed both in legacy settings and in the params has `Register` invoked once per path (client-side loading dedupes by module name).
59+
- `AddIgniteUIBlazor(null)` is now a compile-time ambiguity (CS0121) between the params and delegate overloads; unusual pattern, no runtime impact.
60+
- Object-initializing `IgbDateRangeValue` in the Change handler triggers two BL0005 analyzer warnings (parameter set outside component) — same class of pre-existing warnings as `Chat.cs`/`Tabs.cs`.
61+
62+
Follow-ups are tracked in `PLAN-TRIMMING-FOLLOWUPS.md` (AOT/`RequiresDynamicCode`, smoke-app coverage, stale-docs cleanup).
63+
64+
🤖 Generated with [Claude Code](https://claude.com/claude-code)

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,10 @@ Build and run the Blazor app.
169169

170170
<!-- ![](images/general/getting-started-blazor-card.jpg) -->
171171

172+
### Publishing with trimming
173+
174+
The library is trim-compatible. Applications publishing with `PublishTrimmed=true` (the Blazor WebAssembly default) should read [TRIMMING.md](TRIMMING.md) for the two things that need app-side care: preserving data item types and using the trim-safe module registration overload.
175+
172176
## Building and Running Locally
173177

174178
**Prerequisites:** [Node.js](https://nodejs.org/) 22 or later.

0 commit comments

Comments
 (0)