Skip to content

Commit 284a055

Browse files
loc: replace reflection-based args with tuple-params + IDictionary overloads
The Message(MessageKey, object?) and RichMessage(MessageKey, object?, ...) overloads built an args dictionary by reflecting over public properties of the passed object — anonymous types or [LocArgs]-marked records. Under NativeAOT the trimmer keeps the ctor + backing fields of the anonymous type (both reachable from the ew { … } call site) but drops the property getters, because nothing statically reads them. GetProperties() then returned an empty array, MessageFormat threw on {name}, the catch swallowed the exception, and the raw ICU pattern leaked through to the UI. This change removes the reflection path entirely and gives callers two AOT-safe shapes: - Message(MessageKey, IDictionary<string, object>?) — explicit dict for cases that already have one (or want to share/build one programmatically). - Message(MessageKey, (string Name, object Value) arg1, params (string Name, object Value)[] more) — compact tuple syntax for the common inline case. ValueTuple is a nominal type with concrete fields; building a Dictionary<string,object> from tuple values needs zero reflection. Same shape for RichMessage. (RichMessage with tags uses the dict overload — params must come last, so the tuple variant can't carry the optional ags argument.) Deleted: - ToArgsDictionary + IsAcceptableArgsContainer + _propertyCache - The IL2026/IL2070 unconditional-suppression attributes those needed - System.Reflection + System.Collections.Concurrent + System.Diagnostics.CodeAnalysis usings (now unused) Migrated callers: - Selftest + AppTests localization fixtures (two files) - IntlAccessorTests, RichMessageTests - SourceRewriter CLI codegen: .Message(key, new { name = expr }) → .Message(key, ("name", expr)). Updated SourceRewriterTests. - Docs: _pipeline/templates/localization.md.dt narrative and _pipeline/apps/localization/App.cs snippets, plus the hand-edited docs/reference/localization-howto.md example. Removed from AOT skip list / docs: - Localization_LocaleSwitching no longer needs to be skipped under AOT ( ests/Reactor.AppTests.Host/SelfTest/SelfTestRunner.cs). - The "Localization with anonymous-type args" row in docs/aot-support.md is gone — there's no anonymous-type path left. Verified: - dotnet build src/Reactor clean (AOT warnings as errors). - 282 localization unit tests pass. - Full JIT selftest: 0 failures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent b2e3093 commit 284a055

12 files changed

Lines changed: 77 additions & 101 deletions

File tree

docs/_pipeline/apps/localization/App.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ public override Element Render()
6969
var title = intl.Message(new MessageKey("App", "title"));
7070
var greeting = intl.Message(
7171
new MessageKey("App", "greeting"),
72-
new { name = "Alice" });
72+
("name", "Alice"));
7373

7474
return VStack(12,
7575
TextBlock(title).FontSize(24).Bold(),
@@ -156,7 +156,7 @@ public override Element Render()
156156
var title = intl.Message(new MessageKey("App", "title"));
157157
var greeting = intl.Message(
158158
new MessageKey("App", "greeting"),
159-
new { name = "World" });
159+
("name", "World"));
160160
return VStack(4,
161161
TextBlock(title).FontSize(18).Bold(),
162162
TextBlock(greeting));

docs/_pipeline/templates/localization.md.dt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ picks up the new locale automatically.
5656

5757
Call `UseIntl()` in any descendant — it is a [hook](hooks.md) — to get the
5858
`IntlAccessor`. Use `.Message()` to look up translated strings by key. Pass
59-
arguments as an anonymous object for interpolation:
59+
arguments as `(name, value)` tuples for interpolation — the compact, AOT-safe
60+
shape supported by the tuple-params overload:
6061

6162
```csharp snippet="localization/useintl-messages"
6263
```

docs/aot-support.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ These subsystems compile cleanly with `IsAotCompatible=true` (the warnings are s
3131
| **DataGrid `AutoColumns<T>`** | Reflects over `T`'s public properties via `TypeRegistry`. The `T` parameter is annotated `[DynamicallyAccessedMembers(PublicProperties)]` so the trimmer keeps the members, but `AutoColumns` ultimately funnels into `TypeRegistry.Resolve`, which is `RequiresUnreferencedCode`. Use explicit `Column<T,V>(…)` definitions instead. | Issue #70 |
3232
| **`UseObservable` on POCOs** | `ObservableTreeTracker` walks public properties via reflection to subscribe to INPC. Observables built explicitly (`Observable<T>`, `IObservableCollection`) are fine; the implicit-INPC path is not. | Issue #70 |
3333
| **Form validation** | `FormField`'s default editor resolution goes through `TypeRegistry`. Same caveat as PropertyGrid. | Issue #70 |
34-
| **Localization with anonymous-type args** | `t.Message(Loc.X, new { count = 3 })` reflects over the anonymous type to build the ICU args dictionary. Use `t.Message(Loc.X, new Dictionary<string, object> { ["count"] = 3 })` under AOT. | Issue #70 |
3534
| **Navigation state JSON** | `NavigationHandle` serializes deep-link state via `JsonSerializer` without a source-generated context. Custom types that ride through navigation state will fail to serialize under AOT. | Issue #70 |
3635
| **Component discovery (`ReactorApp.Run<TApp>` reflection paths)** | The instantiation of `TApp` itself is annotated and works. The devtools-only `--list-components` enumeration scans `Assembly.GetTypes`; that path is gated to non-AOT builds. | Issue #70 |
3736
| **Theme resource lookup (`Theme.X`, `ThemeRef.Resolve`)** | `ThemeRef.Resolve` walks `Application.Current.Resources` + its merged/theme dictionaries. The token records (`Theme.Accent`, `Theme.PrimaryText`, …) construct fine, but at runtime the `XamlControlsResources` entries that `ReactorApplication.xaml` brings in aren't fully populated under AOT — `Resolve` returns `null` for keys that exist under the JIT. Brushes applied via `.Foreground(Theme.X)` will fall back to control defaults. | Issue #70 |

docs/reference/localization-howto.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,7 @@ public override Element Render()
379379
380380
return VStack(
381381
Text(t.Message(Loc.MyComponent.Title)),
382-
Text(t.Message(Loc.MyComponent.ItemCount, new { count = items.Count })),
382+
Text(t.Message(Loc.MyComponent.ItemCount, ("count", items.Count))),
383383
Text(t.FormatDate(DateTimeOffset.Now)),
384384
Text(t.FormatNumber(price)),
385385
Text(t.FormatList(names, ListFormatType.Conjunction))

src/Reactor.Cli/Loc/SourceRewriter.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,15 +76,15 @@ private static string BuildReplacement(KeyedLocString entry)
7676
foreach (var param in icuParams)
7777
{
7878
if (argMap.TryGetValue(param, out var exprText) && param != exprText)
79-
argParts.Add($"{param} = {exprText}");
79+
argParts.Add($"(\"{param}\", {exprText})");
8080
else
81-
argParts.Add(param); // direct variable reference
81+
argParts.Add($"(\"{param}\", {param})");
8282
}
8383

8484
if (argParts.Count > 0)
8585
{
8686
var args = string.Join(", ", argParts);
87-
return $"t.Message({locPath}, new {{ {args} }})";
87+
return $"t.Message({locPath}, {args})";
8888
}
8989
}
9090

src/Reactor/Core/Localization/IntlAccessor.cs

Lines changed: 40 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
1-
using System.Collections.Concurrent;
21
using System.Diagnostics;
3-
using System.Diagnostics.CodeAnalysis;
42
using System.Globalization;
5-
using System.Reflection;
63
using System.Text.RegularExpressions;
74
using Microsoft.UI.Reactor.Core;
85
using Microsoft.UI.Xaml;
@@ -21,7 +18,6 @@ public sealed class IntlAccessor
2118
private readonly CultureInfo _culture;
2219
private readonly bool _pseudoLocalize;
2320
private readonly Dictionary<string, string> _assetCache = new();
24-
private static readonly ConcurrentDictionary<Type, PropertyInfo[]> _propertyCache = new();
2521

2622
public IntlAccessor(
2723
string locale,
@@ -54,7 +50,7 @@ public IntlAccessor(
5450
/// Loads a string resource by key, then formats it with ICU MessageFormat.
5551
/// Falls back to the default locale if the key is missing in the current locale.
5652
/// </summary>
57-
public string Message(MessageKey key, object? args = null)
53+
public string Message(MessageKey key, IDictionary<string, object>? args = null)
5854
{
5955
var pattern = ResolvePattern(key);
6056
if (pattern is null)
@@ -63,13 +59,9 @@ public string Message(MessageKey key, object? args = null)
6359
string result;
6460
try
6561
{
66-
if (args is null)
67-
result = _messageCache.Format(Locale, pattern);
68-
else
69-
{
70-
var dict = ToArgsDictionary(args);
71-
result = _messageCache.Format(Locale, pattern, dict);
72-
}
62+
result = args is null
63+
? _messageCache.Format(Locale, pattern)
64+
: _messageCache.Format(Locale, pattern, args);
7365
}
7466
catch (Exception ex)
7567
{
@@ -83,6 +75,21 @@ public string Message(MessageKey key, object? args = null)
8375
return _pseudoLocalize ? PseudoLocalizer.Transform(result) : result;
8476
}
8577

78+
/// <summary>
79+
/// Compact tuple-args overload for the common case of formatting a single
80+
/// message with a handful of ICU placeholders. Builds a plain
81+
/// <see cref="Dictionary{TKey, TValue}"/> in-place — no reflection, AOT-safe.
82+
/// </summary>
83+
/// <example>
84+
/// <code>
85+
/// t.Message(Greeting, ("name", "World"));
86+
/// t.Message(SearchResults, ("count", 0), ("query", "test"));
87+
/// </code>
88+
/// </example>
89+
public string Message(MessageKey key, (string Name, object Value) arg1,
90+
params (string Name, object Value)[] more)
91+
=> Message(key, BuildArgs(arg1, more));
92+
8693
/// <summary>
8794
/// Formats a message that contains rich text tags (e.g., &lt;bold&gt;text&lt;/bold&gt;),
8895
/// mapping each tag to an element factory. Returns a GroupElement containing the
@@ -94,7 +101,7 @@ public string Message(MessageKey key, object? args = null)
94101
/// rendered as plain text (tag markers stripped). Nested tags are not supported — only
95102
/// the outermost tag is processed.
96103
/// </remarks>
97-
public Element RichMessage(MessageKey key, object? args = null,
104+
public Element RichMessage(MessageKey key, IDictionary<string, object>? args = null,
98105
Dictionary<string, Func<string, Element>>? tags = null)
99106
{
100107
var pattern = ResolvePattern(key);
@@ -115,8 +122,7 @@ public Element RichMessage(MessageKey key, object? args = null,
115122
// BEFORE formatting so a translator-controlled arg can't
116123
// mint a `<link>` tag that ParseRichText would dispatch to a
117124
// developer-supplied factory.
118-
var dict = ToArgsDictionary(args);
119-
var escaped = EscapeForRichTags(dict);
125+
var escaped = EscapeForRichTags(args);
120126
formatted = _messageCache.Format(Locale, pattern, escaped);
121127
}
122128
}
@@ -136,6 +142,16 @@ public Element RichMessage(MessageKey key, object? args = null,
136142
return ParseRichText(formatted, tags);
137143
}
138144

145+
/// <summary>
146+
/// Compact tuple-args overload of <see cref="RichMessage(MessageKey, IDictionary{string, object}?, Dictionary{string, Func{string, Element}}?)"/>
147+
/// — same allocation profile as the tuple-args <c>Message</c> overload.
148+
/// Tags must be supplied via the dict-based overload; this variant is for
149+
/// the common case where the developer just wants the formatted text.
150+
/// </summary>
151+
public Element RichMessage(MessageKey key, (string Name, object Value) arg1,
152+
params (string Name, object Value)[] more)
153+
=> RichMessage(key, BuildArgs(arg1, more));
154+
139155
/// <summary>
140156
/// Resolves a locale-qualified asset path. Falls back to the unqualified path
141157
/// if no locale-specific asset exists.
@@ -327,47 +343,16 @@ private static Element ParseRichText(string formatted, Dictionary<string, Func<s
327343
return null;
328344
}
329345

330-
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "ToArgsDictionary uses reflection on anonymous types for localization args.")]
331-
[UnconditionalSuppressMessage("Trimming", "IL2070", Justification = "ToArgsDictionary uses reflection on anonymous types for localization args.")]
332-
private static IDictionary<string, object> ToArgsDictionary(object args)
333-
{
334-
if (args is IDictionary<string, object> dict)
335-
return dict;
336-
337-
// SECURITY (TASK-051): refuse arbitrary DTOs. Anonymous types and the
338-
// [LocArgs]-marked record contract are the only accepted shapes;
339-
// anything else (e.g. a domain DTO with `AccessToken`/`Email`/...)
340-
// would expose every public property to translator-controlled patterns.
341-
var type = args.GetType();
342-
if (!IsAcceptableArgsContainer(type))
343-
throw new ArgumentException(
344-
$"Localization args must be an IDictionary<string,object>, an anonymous type, or a record marked with [LocArgs]. Got '{type.FullName}'.",
345-
nameof(args));
346-
347-
var result = new Dictionary<string, object>();
348-
var props = _propertyCache.GetOrAdd(type,
349-
t => t.GetProperties(BindingFlags.Public | BindingFlags.Instance));
350-
foreach (var prop in props)
351-
{
352-
var value = prop.GetValue(args);
353-
if (value is not null)
354-
result[prop.Name] = value;
355-
}
356-
return result;
357-
}
358-
359-
private static bool IsAcceptableArgsContainer(Type type)
346+
private static Dictionary<string, object> BuildArgs(
347+
(string Name, object Value) arg1,
348+
(string Name, object Value)[] more)
360349
{
361-
// Anonymous types are sealed, generic, public-property-only with a
362-
// compiler-generated name like `<>f__AnonymousType0`.
363-
if (type.Name.StartsWith("<>", StringComparison.Ordinal)) return true;
364-
// Allow opt-in via [LocArgs] attribute name match.
365-
foreach (var attr in type.GetCustomAttributes(inherit: false))
350+
var dict = new Dictionary<string, object>(more.Length + 1)
366351
{
367-
var n = attr.GetType().Name;
368-
if (n == "LocArgsAttribute" || n == "LocArgs") return true;
369-
}
370-
return false;
352+
[arg1.Name] = arg1.Value,
353+
};
354+
foreach (var (k, v) in more) dict[k] = v;
355+
return dict;
371356
}
372357

373358
/// <summary>
@@ -396,7 +381,7 @@ private static string SanitizeBidi(string text)
396381

397382
/// <summary>
398383
/// HTML-escapes string-valued args before they are substituted into a
399-
/// pattern that <see cref="RichMessage"/> will tag-parse. TASK-053.
384+
/// pattern that the <c>RichMessage</c> overloads will tag-parse. TASK-053.
400385
/// Non-string values pass through untouched.
401386
/// </summary>
402387
private static IDictionary<string, object> EscapeForRichTags(IDictionary<string, object> args)

tests/Reactor.AppTests.Host/Fixtures/LocalizationFixtures.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -136,18 +136,18 @@ public override Element Render()
136136
VStack(8,
137137
TextBlock(t.Message(AppTitle)).Set(tb => tb.FontSize = 24).AutomationId("LocTitle"),
138138
TextBlock(t.Message(Welcome)).AutomationId("LocWelcome"),
139-
TextBlock(t.Message(Greeting, new { name = "World" })).AutomationId("LocGreeting"),
139+
TextBlock(t.Message(Greeting, ("name", "World"))).AutomationId("LocGreeting"),
140140

141141
// Plural scenarios
142-
TextBlock(t.Message(ItemCount, new { count = 0 })).AutomationId("LocPluralZero"),
143-
TextBlock(t.Message(ItemCount, new { count = 5 })).AutomationId("LocPluralFive"),
142+
TextBlock(t.Message(ItemCount, ("count", 0))).AutomationId("LocPluralZero"),
143+
TextBlock(t.Message(ItemCount, ("count", 5))).AutomationId("LocPluralFive"),
144144

145145
// Search-results with multiple plural + param
146-
TextBlock(t.Message(SearchResults, new { count = 0, query = locale == "ko-KR" ? "\ud14c\uc2a4\ud2b8" : "test" }))
146+
TextBlock(t.Message(SearchResults, ("count", 0), ("query", locale == "ko-KR" ? "\ud14c\uc2a4\ud2b8" : "test")))
147147
.AutomationId("LocSearchZero"),
148-
TextBlock(t.Message(SearchResults, new { count = 1, query = locale == "ko-KR" ? "\ud14c\uc2a4\ud2b8" : "test" }))
148+
TextBlock(t.Message(SearchResults, ("count", 1), ("query", locale == "ko-KR" ? "\ud14c\uc2a4\ud2b8" : "test")))
149149
.AutomationId("LocSearchOne"),
150-
TextBlock(t.Message(SearchResults, new { count = 42, query = locale == "ko-KR" ? "\ud14c\uc2a4\ud2b8" : "test" }))
150+
TextBlock(t.Message(SearchResults, ("count", 42), ("query", locale == "ko-KR" ? "\ud14c\uc2a4\ud2b8" : "test")))
151151
.AutomationId("LocSearchMany"),
152152

153153
// Direction label

tests/Reactor.AppTests.Host/SelfTest/Fixtures/LocalizationFixtures.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -139,14 +139,14 @@ public override async Task RunAsync()
139139
VStack(8,
140140
TextBlock(t.Message(AppTitle)).Set(tb => tb.FontSize = 24),
141141
TextBlock(t.Message(Welcome)),
142-
TextBlock(t.Message(Greeting, new { name = "World" })),
142+
TextBlock(t.Message(Greeting, ("name", "World"))),
143143

144-
TextBlock(t.Message(ItemCount, new { count = 0 })),
145-
TextBlock(t.Message(ItemCount, new { count = 5 })),
144+
TextBlock(t.Message(ItemCount, ("count", 0))),
145+
TextBlock(t.Message(ItemCount, ("count", 5))),
146146

147-
TextBlock(t.Message(SearchResults, new { count = 0, query = locale == "ko-KR" ? "\ud14c\uc2a4\ud2b8" : "test" })),
148-
TextBlock(t.Message(SearchResults, new { count = 1, query = locale == "ko-KR" ? "\ud14c\uc2a4\ud2b8" : "test" })),
149-
TextBlock(t.Message(SearchResults, new { count = 42, query = locale == "ko-KR" ? "\ud14c\uc2a4\ud2b8" : "test" })),
147+
TextBlock(t.Message(SearchResults, ("count", 0), ("query", locale == "ko-KR" ? "\ud14c\uc2a4\ud2b8" : "test"))),
148+
TextBlock(t.Message(SearchResults, ("count", 1), ("query", locale == "ko-KR" ? "\ud14c\uc2a4\ud2b8" : "test"))),
149+
TextBlock(t.Message(SearchResults, ("count", 42), ("query", locale == "ko-KR" ? "\ud14c\uc2a4\ud2b8" : "test"))),
150150

151151
TextBlock(t.Message(DirectionLabel))
152152
)

tests/Reactor.AppTests.Host/SelfTest/SelfTestRunner.cs

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -137,18 +137,11 @@ internal static class SelfTestRunner
137137
// `docs/aot-support.md` showed every remaining failure is a fixture
138138
// that exercises a subsystem already documented as not-yet-AOT-clean:
139139
// PropertyGrid auto-discovery, devtools/MCP reflection, UseObservable
140-
// on POCOs, anonymous-type localization args, theme resource lookup,
141-
// and XAML-metadata-dependent control hosting. Skipping them gives a
142-
// 0-failure AOT run that maps cleanly to the documented surface, so a
143-
// future fix for any one subsystem (e.g. source-generated PropertyGrid
144-
// metadata) translates directly into selftests being re-enabled here.
145-
146-
// Localization: t.Message(key, new { name = "..." }) reflects on the
147-
// anonymous type's properties to build the ICU args dict; properties
148-
// get trimmed under AOT. Documented workaround in aot-support.md is
149-
// `new Dictionary<string,object> { ["name"] = "..." }`. Whole fixture
150-
// is anonymous-type-driven.
151-
"Localization_LocaleSwitching",
140+
// on POCOs, theme resource lookup, and XAML-metadata-dependent control
141+
// hosting. Skipping them gives a 0-failure AOT run that maps cleanly
142+
// to the documented surface, so a future fix for any one subsystem
143+
// (e.g. source-generated PropertyGrid metadata) translates directly
144+
// into selftests being re-enabled here.
152145

153146
// PropertyGrid auto-discovery: ReflectionTypeMetadataProvider walks
154147
// public properties + builds init-only setters. AOT trims members of

tests/Reactor.Tests/Localization/IntlAccessorTests.cs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ public void Message_WithInterpolation_FormatsArgs()
3030
.Add("en-US", "Settings", "LoggedInAs", "Logged in as {name}");
3131

3232
var t = CreateAccessor("en-US", provider);
33-
var result = t.Message(new MessageKey("Settings", "LoggedInAs"), new { name = "Alice" });
33+
var result = t.Message(new MessageKey("Settings", "LoggedInAs"), ("name", "Alice"));
3434
Assert.Equal("Logged in as Alice", result);
3535
}
3636

@@ -43,9 +43,9 @@ public void Message_IcuPlurals_SelectsCorrectForm()
4343

4444
var t = CreateAccessor("en-US", provider);
4545

46-
Assert.Equal("Your cart is empty", t.Message(new MessageKey("Cart", "ItemCount"), new { count = 0 }));
47-
Assert.Equal("1 item in cart", t.Message(new MessageKey("Cart", "ItemCount"), new { count = 1 }));
48-
Assert.Equal("5 items in cart", t.Message(new MessageKey("Cart", "ItemCount"), new { count = 5 }));
46+
Assert.Equal("Your cart is empty", t.Message(new MessageKey("Cart", "ItemCount"), ("count", 0)));
47+
Assert.Equal("1 item in cart", t.Message(new MessageKey("Cart", "ItemCount"), ("count", 1)));
48+
Assert.Equal("5 items in cart", t.Message(new MessageKey("Cart", "ItemCount"), ("count", 5)));
4949
}
5050

5151
[Fact]
@@ -57,9 +57,9 @@ public void Message_IcuSelect_SelectsCorrectBranch()
5757

5858
var t = CreateAccessor("en-US", provider);
5959

60-
Assert.Equal("He joined", t.Message(new MessageKey("Profile", "Greeting"), new { gender = "male" }));
61-
Assert.Equal("She joined", t.Message(new MessageKey("Profile", "Greeting"), new { gender = "female" }));
62-
Assert.Equal("They joined", t.Message(new MessageKey("Profile", "Greeting"), new { gender = "other" }));
60+
Assert.Equal("He joined", t.Message(new MessageKey("Profile", "Greeting"), ("gender", "male")));
61+
Assert.Equal("She joined", t.Message(new MessageKey("Profile", "Greeting"), ("gender", "female")));
62+
Assert.Equal("They joined", t.Message(new MessageKey("Profile", "Greeting"), ("gender", "other")));
6363
}
6464

6565
[Fact]

0 commit comments

Comments
 (0)