Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ Output is the same TAP stream as a normal selftest run. The runner detects AOT a

A native crash terminates the AOT process — the per-fixture managed watchdog can't fire. Iterate by tailing the TAP output for the *last* `# Running: <name>` line before exit, add that name to the skip list, and re-run. Be conservative when wildcarding a family: many `Family_*` fixtures pass even when one member crashes.

**4. Expected pass count.** As of 2026-05-20, an AOT run of the suite produces roughly: 735 fixtures total → 193 skipped, ~542 passed, 0 failed. The skip list covers fixtures that exercise subsystems documented as not-yet-AOT-clean in [`docs/aot-support.md`](docs/aot-support.md) (PropertyGrid auto-discovery, devtools/MCP, UseObservable on POCOs, anonymous-type localization args, theme resource lookup, XAML-metadata-dependent control hosting). When you fix one of those subsystems, drop the corresponding entries from `DefaultAotSkipPatterns`. The non-AOT run on the same commit is 735/735 pass.
**4. Expected pass count.** As of 2026-05-20, an AOT run of the suite produces roughly: 735 fixtures total → 192 skipped, ~543 passed, 0 failed. The skip list covers fixtures that exercise subsystems documented as not-yet-AOT-clean in [`docs/aot-support.md`](docs/aot-support.md) (PropertyGrid auto-discovery, devtools/MCP, UseObservable on POCOs, theme resource lookup, XAML-metadata-dependent control hosting). When you fix one of those subsystems, drop the corresponding entries from `DefaultAotSkipPatterns`. The non-AOT run on the same commit is 735/735 pass.

### 3. E2E tests (`tests/Reactor.AppTests`) — MSTest + WinAppDriver

Expand Down
4 changes: 2 additions & 2 deletions docs/_pipeline/apps/localization/App.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ public override Element Render()
var title = intl.Message(new MessageKey("App", "title"));
var greeting = intl.Message(
new MessageKey("App", "greeting"),
new { name = "Alice" });
("name", "Alice"));

return VStack(12,
TextBlock(title).FontSize(24).Bold(),
Expand Down Expand Up @@ -156,7 +156,7 @@ public override Element Render()
var title = intl.Message(new MessageKey("App", "title"));
var greeting = intl.Message(
new MessageKey("App", "greeting"),
new { name = "World" });
("name", "World"));
return VStack(4,
TextBlock(title).FontSize(18).Bold(),
TextBlock(greeting));
Expand Down
5 changes: 4 additions & 1 deletion docs/_pipeline/templates/localization.md.dt
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,10 @@ picks up the new locale automatically.

Call `UseIntl()` in any descendant — it is a [hook](hooks.md) — to get the
`IntlAccessor`. Use `.Message()` to look up translated strings by key. Pass
arguments as an anonymous object for interpolation:
arguments as `("name", value)` tuples for interpolation — the first item is
the placeholder name as a string literal (matching the `{name}` in the
`.resw` pattern), the second is the value. This is the compact, AOT-safe
shape supported by the tuple-params overload:

```csharp snippet="localization/useintl-messages"
```
Expand Down
1 change: 0 additions & 1 deletion docs/aot-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ These subsystems compile cleanly with `IsAotCompatible=true` (the warnings are s
| **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 |
| **`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 |
| **Form validation** | `FormField`'s default editor resolution goes through `TypeRegistry`. Same caveat as PropertyGrid. | Issue #70 |
| **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 |
| **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 |
| **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 |
| **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 |
Expand Down
9 changes: 6 additions & 3 deletions docs/guide/localization.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,10 @@ picks up the new locale automatically.

Call `UseIntl()` in any descendant — it is a [hook](hooks.md) — to get the
`IntlAccessor`. Use `.Message()` to look up translated strings by key. Pass
arguments as an anonymous object for interpolation:
arguments as `("name", value)` tuples for interpolation — the first item is
the placeholder name as a string literal (matching the `{name}` in the
`.resw` pattern), the second is the value. This is the compact, AOT-safe
shape supported by the tuple-params overload:

```csharp
class LocalizedContent : Component
Expand All @@ -101,7 +104,7 @@ class LocalizedContent : Component
var title = intl.Message(new MessageKey("App", "title"));
var greeting = intl.Message(
new MessageKey("App", "greeting"),
new { name = "Alice" });
("name", "Alice"));

return VStack(12,
TextBlock(title).FontSize(24).Bold(),
Expand Down Expand Up @@ -228,7 +231,7 @@ class PseudoLocDemo : Component
var title = intl.Message(new MessageKey("App", "title"));
var greeting = intl.Message(
new MessageKey("App", "greeting"),
new { name = "World" });
("name", "World"));
return VStack(4,
TextBlock(title).FontSize(18).Bold(),
TextBlock(greeting));
Expand Down
8 changes: 4 additions & 4 deletions docs/guide/navigation.md
Original file line number Diff line number Diff line change
Expand Up @@ -640,10 +640,10 @@ class DiagnosticsDemo : Component

UseEffect(() =>
{
Action<NavigationDiagnosticEvent> onRequested =
e => updateLog(l => [.. l, $"Requested: {e.From} → {e.To}"]);
Action<NavigationDiagnosticEvent> onCompleted =
e => updateLog(l => [.. l, $"Completed: {e.To}"]);
EventHandler<NavigationDiagnosticEvent> onRequested =
(_, e) => updateLog(l => [.. l, $"Requested: {e.From} → {e.To}"]);
EventHandler<NavigationDiagnosticEvent> onCompleted =
(_, e) => updateLog(l => [.. l, $"Completed: {e.To}"]);

NavigationDiagnostics.NavigationRequested += onRequested;
NavigationDiagnostics.NavigationCompleted += onCompleted;
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/localization-howto.md
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,7 @@ public override Element Render()

return VStack(
Text(t.Message(Loc.MyComponent.Title)),
Text(t.Message(Loc.MyComponent.ItemCount, new { count = items.Count })),
Text(t.Message(Loc.MyComponent.ItemCount, ("count", items.Count))),
Text(t.FormatDate(DateTimeOffset.Now)),
Text(t.FormatNumber(price)),
Text(t.FormatList(names, ListFormatType.Conjunction))
Expand Down
11 changes: 8 additions & 3 deletions src/Reactor.Cli/Loc/SourceRewriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,16 +75,21 @@ private static string BuildReplacement(KeyedLocString entry)
var argParts = new List<string>();
foreach (var param in icuParams)
{
// The ICU param name becomes a string literal in the emitted
// source, so it has to be C#-escaped. Most ICU params are
// identifiers, but the spec doesn't forbid `"` or `\` —
// FormatLiteral keeps the emission valid in those cases.
var keyLit = SymbolDisplay.FormatLiteral(param, quote: true);
if (argMap.TryGetValue(param, out var exprText) && param != exprText)
argParts.Add($"{param} = {exprText}");
argParts.Add($"({keyLit}, {exprText})");
else
argParts.Add(param); // direct variable reference
argParts.Add($"({keyLit}, {param})");
}

if (argParts.Count > 0)
{
var args = string.Join(", ", argParts);
return $"t.Message({locPath}, new {{ {args} }})";
return $"t.Message({locPath}, {args})";
Comment thread
codemonkeychris marked this conversation as resolved.
}
}

Expand Down
104 changes: 50 additions & 54 deletions src/Reactor/Core/Localization/IntlAccessor.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.Tracing;
using System.Globalization;
using System.Reflection;
using System.Text.RegularExpressions;
using Microsoft.UI.Reactor.Core;
using Microsoft.UI.Reactor.Core.Diagnostics;
Expand All @@ -22,7 +19,6 @@ public sealed class IntlAccessor
private readonly CultureInfo _culture;
private readonly bool _pseudoLocalize;
private readonly Dictionary<string, string> _assetCache = new();
private static readonly ConcurrentDictionary<Type, PropertyInfo[]> _propertyCache = new();

public IntlAccessor(
string locale,
Expand Down Expand Up @@ -55,7 +51,7 @@ public IntlAccessor(
/// Loads a string resource by key, then formats it with ICU MessageFormat.
/// Falls back to the default locale if the key is missing in the current locale.
/// </summary>
public string Message(MessageKey key, object? args = null)
public string Message(MessageKey key, IDictionary<string, object>? args = null)
{
var pattern = ResolvePattern(key);
if (pattern is null)
Expand All @@ -64,13 +60,9 @@ public string Message(MessageKey key, object? args = null)
string result;
try
{
if (args is null)
result = _messageCache.Format(Locale, pattern);
else
{
var dict = ToArgsDictionary(args);
result = _messageCache.Format(Locale, pattern, dict);
}
result = args is null
? _messageCache.Format(Locale, pattern)
: _messageCache.Format(Locale, pattern, args);
}
catch (Exception ex)
{
Expand All @@ -84,6 +76,27 @@ public string Message(MessageKey key, object? args = null)
return _pseudoLocalize ? PseudoLocalizer.Transform(result) : result;
}

/// <summary>
/// Compact tuple-args overload for the common case of formatting a single
/// message with a handful of ICU placeholders. Builds a plain
/// <see cref="Dictionary{TKey, TValue}"/> in-place — no reflection, AOT-safe.
/// </summary>
/// <remarks>
/// Tuples with a null <c>Value</c> are dropped before the dictionary is
/// built — this matches the behavior of the prior reflection path, which
/// skipped null-valued properties so the formatter saw the placeholder as
/// "missing" rather than substituting an empty string.
/// </remarks>
/// <example>
/// <code>
/// t.Message(Greeting, ("name", "World"));
/// t.Message(SearchResults, ("count", 0), ("query", "test"));
/// </code>
/// </example>
public string Message(MessageKey key, (string Name, object? Value) arg1,
params (string Name, object? Value)[] more)
=> Message(key, BuildArgs(arg1, more));

/// <summary>
/// Formats a message that contains rich text tags (e.g., &lt;bold&gt;text&lt;/bold&gt;),
/// mapping each tag to an element factory. Returns a GroupElement containing the
Expand All @@ -95,7 +108,7 @@ public string Message(MessageKey key, object? args = null)
/// rendered as plain text (tag markers stripped). Nested tags are not supported — only
/// the outermost tag is processed.
/// </remarks>
public Element RichMessage(MessageKey key, object? args = null,
public Element RichMessage(MessageKey key, IDictionary<string, object>? args = null,
Dictionary<string, Func<string, Element>>? tags = null)
{
var pattern = ResolvePattern(key);
Expand All @@ -116,8 +129,7 @@ public Element RichMessage(MessageKey key, object? args = null,
// BEFORE formatting so a translator-controlled arg can't
// mint a `<link>` tag that ParseRichText would dispatch to a
// developer-supplied factory.
var dict = ToArgsDictionary(args);
var escaped = EscapeForRichTags(dict);
var escaped = EscapeForRichTags(args);
formatted = _messageCache.Format(Locale, pattern, escaped);
}
}
Expand All @@ -137,6 +149,17 @@ public Element RichMessage(MessageKey key, object? args = null,
return ParseRichText(formatted, tags);
}

/// <summary>
/// Compact tuple-args overload of the dict-based <c>RichMessage</c> —
/// same allocation profile as the tuple-args <c>Message</c> overload.
/// Tags must be supplied via the dict-based overload; this variant is for
/// the common case where the developer just wants the formatted text.
/// Null-valued tuples are dropped (see the tuple-args <c>Message</c> remarks).
/// </summary>
Comment thread
codemonkeychris marked this conversation as resolved.
public Element RichMessage(MessageKey key, (string Name, object? Value) arg1,
params (string Name, object? Value)[] more)
=> RichMessage(key, BuildArgs(arg1, more));

/// <summary>
/// Resolves a locale-qualified asset path. Falls back to the unqualified path
/// if no locale-specific asset exists.
Expand Down Expand Up @@ -334,47 +357,20 @@ private static Element ParseRichText(string formatted, Dictionary<string, Func<s
return null;
}

[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "ToArgsDictionary uses reflection on anonymous types for localization args.")]
[UnconditionalSuppressMessage("Trimming", "IL2070", Justification = "ToArgsDictionary uses reflection on anonymous types for localization args.")]
private static IDictionary<string, object> ToArgsDictionary(object args)
{
if (args is IDictionary<string, object> dict)
return dict;

// SECURITY (TASK-051): refuse arbitrary DTOs. Anonymous types and the
// [LocArgs]-marked record contract are the only accepted shapes;
// anything else (e.g. a domain DTO with `AccessToken`/`Email`/...)
// would expose every public property to translator-controlled patterns.
var type = args.GetType();
if (!IsAcceptableArgsContainer(type))
throw new ArgumentException(
$"Localization args must be an IDictionary<string,object>, an anonymous type, or a record marked with [LocArgs]. Got '{type.FullName}'.",
nameof(args));

var result = new Dictionary<string, object>();
var props = _propertyCache.GetOrAdd(type,
t => t.GetProperties(BindingFlags.Public | BindingFlags.Instance));
foreach (var prop in props)
{
var value = prop.GetValue(args);
if (value is not null)
result[prop.Name] = value;
}
return result;
}

private static bool IsAcceptableArgsContainer(Type type)
// Null-valued tuples are skipped so the formatter sees a "missing"
// placeholder rather than receiving a null arg — preserves the behavior
// of the prior reflection path, which dropped null-valued properties.
private static Dictionary<string, object> BuildArgs(
(string Name, object? Value) arg1,
(string Name, object? Value)[] more)
{
// Anonymous types are sealed, generic, public-property-only with a
// compiler-generated name like `<>f__AnonymousType0`.
if (type.Name.StartsWith("<>", StringComparison.Ordinal)) return true;
// Allow opt-in via [LocArgs] attribute name match.
foreach (var attr in type.GetCustomAttributes(inherit: false))
var dict = new Dictionary<string, object>(more.Length + 1);
if (arg1.Value is not null) dict[arg1.Name] = arg1.Value;
foreach (var (k, v) in more)
{
var n = attr.GetType().Name;
if (n == "LocArgsAttribute" || n == "LocArgs") return true;
if (v is not null) dict[k] = v;
}
return false;
return dict;
}
Comment thread
codemonkeychris marked this conversation as resolved.

/// <summary>
Expand Down Expand Up @@ -403,7 +399,7 @@ private static string SanitizeBidi(string text)

/// <summary>
/// HTML-escapes string-valued args before they are substituted into a
/// pattern that <see cref="RichMessage"/> will tag-parse. TASK-053.
/// pattern that the <c>RichMessage</c> overloads will tag-parse. TASK-053.
/// Non-string values pass through untouched.
/// </summary>
private static IDictionary<string, object> EscapeForRichTags(IDictionary<string, object> args)
Expand Down
12 changes: 6 additions & 6 deletions tests/Reactor.AppTests.Host/Fixtures/LocalizationFixtures.cs
Original file line number Diff line number Diff line change
Expand Up @@ -136,18 +136,18 @@ public override Element Render()
VStack(8,
TextBlock(t.Message(AppTitle)).Set(tb => tb.FontSize = 24).AutomationId("LocTitle"),
TextBlock(t.Message(Welcome)).AutomationId("LocWelcome"),
TextBlock(t.Message(Greeting, new { name = "World" })).AutomationId("LocGreeting"),
TextBlock(t.Message(Greeting, ("name", "World"))).AutomationId("LocGreeting"),

// Plural scenarios
TextBlock(t.Message(ItemCount, new { count = 0 })).AutomationId("LocPluralZero"),
TextBlock(t.Message(ItemCount, new { count = 5 })).AutomationId("LocPluralFive"),
TextBlock(t.Message(ItemCount, ("count", 0))).AutomationId("LocPluralZero"),
TextBlock(t.Message(ItemCount, ("count", 5))).AutomationId("LocPluralFive"),

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

// Direction label
Expand Down
Loading
Loading