Skip to content

Commit 6e575e2

Browse files
AOT batch: nav POCO state, DataGrid DAM, ArrayOps runtime gate
Three independent AOT compatibility improvements from issue #70: 1. NavigationHandle no longer dictates serialization. GetState() returns a new public NavigationState<TRoute> record; SetState takes that record. Apps pick their own serializer (JSON via JsonSerializerContext, MessagePack, binary, etc.). Removes the IL2026/IL3050 suppressions that were on GetState/SetState and drops System.Text.Json from the framework call site. 2. DataGrid<T> propagates [DynamicallyAccessedMembers(PublicProperties | PublicConstructors)] from the column-discovery code (AutoColumns<T>) up through DataGridElement<T>, DataGridComponent<T>, both factory overloads, and SelectionChanged<T>. The blanket #pragma warning disable IL2091 is gone — the analyzer is satisfied without suppression. 3. ArrayOperations.Add / RemoveAt: replace [RequiresDynamicCode] with a RuntimeFeature.IsDynamicCodeSupported check around Array.CreateInstance, plus a focused [UnconditionalSuppressMessage(""AOT"", ""IL3050"")]. The List<T>/ObservableCollection<T> path is now fully AOT-safe; the rare T[] path throws NotSupportedException only when actually exercised under native AOT. Tests / samples / docs updated to teach the POCO + JsonSerializerContext pattern. NavigationDemo's AppRoute gains [JsonPolymorphic] / [JsonDerivedType] so polymorphic state round-trips correctly. Build: 0 errors, 0 warnings. Reactor.Tests: 8130 passed, 46 skipped, 0 failed. Reactor.SelfTests: 735 passed, 0 failed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c652b0d commit 6e575e2

19 files changed

Lines changed: 264 additions & 133 deletions

File tree

docs/_pipeline/ai-author-skill.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -562,7 +562,7 @@ class MyComponent : Component<MyProps>
562562
`.CanGoForward`, `.ForwardStack`, `.GoForward()` — forward navigation,
563563
`.PopTo(predicate)` — pop until matching route,
564564
`.Navigate(route, NavigateOptions)` — with transition override and `PushToBackStack` flag,
565-
`.GetState(options?)` / `.SetState(json)`serialize/restore full nav state.
565+
`.GetState()` / `.SetState(state)`capture/restore full nav state as a `NavigationState<TRoute>` POCO (serialize externally — e.g. `JsonSerializer.Serialize(snapshot, MyJsonContext.Default.NavigationStateRoute)`).
566566

567567
**Validation & forms:**
568568

docs/_pipeline/apps/navigation/App.cs

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -243,29 +243,37 @@ class StateSerializationDemo : Component
243243
public override Element Render()
244244
{
245245
var nav = UseNavigation(Route.Home);
246-
var (savedState, setSavedState) = UseState<string?>(null);
246+
var (savedJson, setSavedJson) = UseState<string?>(null);
247247

248248
return VStack(12,
249249
SubHeading("State Serialization"),
250250
HStack(8,
251251
Button("Navigate", () =>
252252
nav.Navigate(Route.Settings)),
253253
Button("Save State", () =>
254-
setSavedState(nav.GetState())),
254+
setSavedJson(System.Text.Json.JsonSerializer.Serialize(
255+
nav.GetState(), DocsNavJsonContext.Default.NavigationStateRoute))),
255256
Button("Restore State", () =>
256257
{
257-
if (savedState is not null)
258-
nav.SetState(savedState);
259-
}).IsEnabled(!(savedState is null))
258+
if (savedJson is not null)
259+
{
260+
var state = System.Text.Json.JsonSerializer.Deserialize(
261+
savedJson, DocsNavJsonContext.Default.NavigationStateRoute);
262+
if (state is not null) nav.SetState(state);
263+
}
264+
}).IsEnabled(savedJson is not null)
260265
),
261266
TextBlock($"Current: {nav.CurrentRoute}"),
262-
TextBlock($"Saved: {savedState?[..Math.Min(50, savedState?.Length ?? 0)] ?? "(none)"}")
267+
TextBlock($"Saved: {savedJson?[..Math.Min(50, savedJson?.Length ?? 0)] ?? "(none)"}")
263268
.FontSize(12).Opacity(0.6),
264269
NavigationHost(nav, route =>
265270
TextBlock($"Page: {route}").Padding(16))
266271
).Padding(24);
267272
}
268273
}
274+
275+
[System.Text.Json.Serialization.JsonSerializable(typeof(NavigationState<Route>))]
276+
partial class DocsNavJsonContext : System.Text.Json.Serialization.JsonSerializerContext { }
269277
// </snippet:state-serialization>
270278

271279
// <snippet:diagnostics>

docs/_pipeline/templates/navigation.md.dt

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ async cancellation.
149149
<!-- ai:caveat -->
150150
`Reset(route)`, `Replace(route)`, and `PopTo(predicate)` all run the
151151
`onNavigatingFrom` guard — `ctx.Cancel()` will block them. But
152-
`SetState(json)` does **not**: it routes through `NavigationStack.RestoreState`
152+
`SetState(state)` does **not**: it routes through `NavigationStack.RestoreState`
153153
which bypasses `InvokeGuard` entirely and fires `Navigated` with
154154
`NavigationMode.Reset` after the stacks are already rewritten. If your
155155
unsaved-changes guard lives only in `onNavigatingFrom`, an app that
@@ -254,20 +254,25 @@ Use tabs when users need to switch freely between parallel workspaces.
254254

255255
## State Serialization
256256

257-
`NavigationHandle` can serialize and restore the full navigation state —
258-
back stack, current route, and forward stack — as JSON. Use this to persist
259-
navigation state across app restarts or to restore deep-linked sessions:
257+
`NavigationHandle` can capture and restore the full navigation state —
258+
back stack, current route, and forward stack — as a plain
259+
`NavigationState<TRoute>` snapshot. Reactor intentionally does **not**
260+
pick a serialization format: persist the snapshot however you like
261+
(JSON via your own source-gen context, MessagePack, hand-rolled binary).
260262

261263
```csharp snippet="navigation/state-serialization"
262264
```
263265

264266
![State serialization](screenshot://navigation/state-serialization)
265267

266-
`GetState()` returns a JSON string. `SetState(json)` restores the stacks
267-
and fires `Navigated` with `Reset` mode. Pass `JsonSerializerOptions` if
268-
your route type needs custom serialization. For polymorphic route
269-
hierarchies, mark the base type with `[JsonPolymorphic]` and
270-
`[JsonDerivedType]` so the round trip preserves the discriminator.
268+
`GetState()` returns a `NavigationState<TRoute>` record. `SetState(state)`
269+
restores the stacks and fires `Navigated` with `Reset` mode. The record
270+
carries `[JsonPropertyName]` metadata so a one-line
271+
`JsonSerializer.Serialize(snapshot, MyJsonContext.Default.NavigationStateRoute)`
272+
gives you camelCase JSON for free — fully AOT-safe when paired with a
273+
`JsonSerializerContext`. For polymorphic route hierarchies, mark the base
274+
type with `[JsonPolymorphic]` and `[JsonDerivedType]` so the round trip
275+
preserves the discriminator.
271276

272277
## Frame Events
273278

docs/guide/navigation.md

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ async cancellation.
243243

244244
> **Caveat:** `Reset(route)`, `Replace(route)`, and `PopTo(predicate)` all run the
245245
> `onNavigatingFrom` guard — `ctx.Cancel()` will block them. But
246-
> `SetState(json)` does **not**: it routes through `NavigationStack.RestoreState`
246+
> `SetState(state)` does **not**: it routes through `NavigationStack.RestoreState`
247247
> which bypasses `InvokeGuard` entirely and fires `Navigated` with
248248
> `NavigationMode.Reset` after the stacks are already rewritten. If your
249249
> unsaved-changes guard lives only in `onNavigatingFrom`, an app that
@@ -491,48 +491,61 @@ Use tabs when users need to switch freely between parallel workspaces.
491491

492492
## State Serialization
493493

494-
`NavigationHandle` can serialize and restore the full navigation state —
495-
back stack, current route, and forward stack — as JSON. Use this to persist
496-
navigation state across app restarts or to restore deep-linked sessions:
494+
`NavigationHandle` can capture and restore the full navigation state —
495+
back stack, current route, and forward stack — as a plain
496+
`NavigationState<TRoute>` snapshot. Reactor intentionally does **not**
497+
pick a serialization format: persist the snapshot however you like
498+
(JSON via your own source-gen context, MessagePack, hand-rolled binary).
497499

498500
```csharp
499501
class StateSerializationDemo : Component
500502
{
501503
public override Element Render()
502504
{
503505
var nav = UseNavigation(Route.Home);
504-
var (savedState, setSavedState) = UseState<string?>(null);
506+
var (savedJson, setSavedJson) = UseState<string?>(null);
505507

506508
return VStack(12,
507509
SubHeading("State Serialization"),
508510
HStack(8,
509511
Button("Navigate", () =>
510512
nav.Navigate(Route.Settings)),
511513
Button("Save State", () =>
512-
setSavedState(nav.GetState())),
514+
setSavedJson(System.Text.Json.JsonSerializer.Serialize(
515+
nav.GetState(), DocsNavJsonContext.Default.NavigationStateRoute))),
513516
Button("Restore State", () =>
514517
{
515-
if (savedState is not null)
516-
nav.SetState(savedState);
517-
}).IsEnabled(!(savedState is null))
518+
if (savedJson is not null)
519+
{
520+
var state = System.Text.Json.JsonSerializer.Deserialize(
521+
savedJson, DocsNavJsonContext.Default.NavigationStateRoute);
522+
if (state is not null) nav.SetState(state);
523+
}
524+
}).IsEnabled(savedJson is not null)
518525
),
519526
TextBlock($"Current: {nav.CurrentRoute}"),
520-
TextBlock($"Saved: {savedState?[..Math.Min(50, savedState?.Length ?? 0)] ?? "(none)"}")
527+
TextBlock($"Saved: {savedJson?[..Math.Min(50, savedJson?.Length ?? 0)] ?? "(none)"}")
521528
.FontSize(12).Opacity(0.6),
522529
NavigationHost(nav, route =>
523530
TextBlock($"Page: {route}").Padding(16))
524531
).Padding(24);
525532
}
526533
}
534+
535+
[System.Text.Json.Serialization.JsonSerializable(typeof(NavigationState<Route>))]
536+
partial class DocsNavJsonContext : System.Text.Json.Serialization.JsonSerializerContext { }
527537
```
528538

529539
![State serialization](images/navigation/state-serialization.png)
530540

531-
`GetState()` returns a JSON string. `SetState(json)` restores the stacks
532-
and fires `Navigated` with `Reset` mode. Pass `JsonSerializerOptions` if
533-
your route type needs custom serialization. For polymorphic route
534-
hierarchies, mark the base type with `[JsonPolymorphic]` and
535-
`[JsonDerivedType]` so the round trip preserves the discriminator.
541+
`GetState()` returns a `NavigationState<TRoute>` record. `SetState(state)`
542+
restores the stacks and fires `Navigated` with `Reset` mode. The record
543+
carries `[JsonPropertyName]` metadata so a one-line
544+
`JsonSerializer.Serialize(snapshot, MyJsonContext.Default.NavigationStateRoute)`
545+
gives you camelCase JSON for free — fully AOT-safe when paired with a
546+
`JsonSerializerContext`. For polymorphic route hierarchies, mark the base
547+
type with `[JsonPolymorphic]` and `[JsonDerivedType]` so the round trip
548+
preserves the discriminator.
536549

537550
## Frame Events
538551

docs/specs/011-navigation-design.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1010,18 +1010,20 @@ restored on relaunch.
10101010
#### API
10111011

10121012
```csharp
1013-
// Save
1014-
string json = nav.GetState();
1015-
// json: {"backStack":[{"$type":"Home"},{"$type":"Detail","Id":42}],
1016-
// "current":{"$type":"Settings"},
1013+
// Save (caller picks the storage format)
1014+
NavigationState<AppRoute> state = nav.GetState();
1015+
string json = JsonSerializer.Serialize(state, AppJsonContext.Default.NavigationStateAppRoute);
1016+
// json: {"backStack":[{"$type":"home"},{"$type":"detail","Id":42}],
1017+
// "current":{"$type":"settings"},
10171018
// "forwardStack":[]}
10181019
10191020
ApplicationData.Current.LocalSettings.Values["nav_state"] = json;
10201021

10211022
// Restore
10221023
if (ApplicationData.Current.LocalSettings.Values.TryGetValue("nav_state", out var saved))
10231024
{
1024-
nav.SetState((string)saved);
1025+
var restored = JsonSerializer.Deserialize((string)saved, AppJsonContext.Default.NavigationStateAppRoute);
1026+
if (restored is not null) nav.SetState(restored);
10251027
}
10261028
```
10271029

plugins/reactor/skills/reactor-navigation/SKILL.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,8 +233,21 @@ Caching preserves scroll position, text input, and component state.
233233
## 10. State serialization
234234

235235
```csharp
236-
var json = nav.GetState(); // serialize full nav state
237-
nav.SetState(json); // restore stacks + navigate
236+
var state = nav.GetState(); // NavigationState<TRoute> snapshot
237+
nav.SetState(state); // restore stacks + fire Navigated(Reset)
238+
```
239+
240+
Reactor returns a plain `NavigationState<TRoute>` POCO and lets you pick the
241+
storage format. For JSON persistence pair the snapshot with a
242+
`JsonSerializerContext` (AOT-safe):
243+
244+
```csharp
245+
[JsonSerializable(typeof(NavigationState<AppRoute>))]
246+
partial class AppJsonContext : JsonSerializerContext { }
247+
248+
var json = JsonSerializer.Serialize(nav.GetState(), AppJsonContext.Default.NavigationStateAppRoute);
249+
// later…
250+
nav.SetState(JsonSerializer.Deserialize(json, AppJsonContext.Default.NavigationStateAppRoute)!);
238251
```
239252

240253
Use for persisting navigation across app restarts.

samples/NavigationDemo/App.cs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,13 @@ static class AuthState
6161

6262
// ─── Route types ──────────────────────────────────────────────────────────────
6363

64+
[System.Text.Json.Serialization.JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
65+
[System.Text.Json.Serialization.JsonDerivedType(typeof(Home), "home")]
66+
[System.Text.Json.Serialization.JsonDerivedType(typeof(Detail), "detail")]
67+
[System.Text.Json.Serialization.JsonDerivedType(typeof(Settings), "settings")]
68+
[System.Text.Json.Serialization.JsonDerivedType(typeof(Profile), "profile")]
69+
[System.Text.Json.Serialization.JsonDerivedType(typeof(DocsPage), "docs")]
70+
[System.Text.Json.Serialization.JsonDerivedType(typeof(AdminPage), "admin")]
6471
abstract record AppRoute;
6572
sealed record Home : AppRoute;
6673
sealed record Detail(int Id, string Tab = "overview") : AppRoute;
@@ -69,6 +76,10 @@ sealed record Profile(string Name) : AppRoute;
6976
sealed record DocsPage(string Path) : AppRoute;
7077
sealed record AdminPage : AppRoute;
7178

79+
// App-side source-gen context for AOT-safe JSON persistence of nav state.
80+
[System.Text.Json.Serialization.JsonSerializable(typeof(NavigationState<AppRoute>))]
81+
partial class NavDemoJsonContext : System.Text.Json.Serialization.JsonSerializerContext { }
82+
7283
// Settings sub-routes for nested navigation
7384
abstract record SettingsRoute;
7485
sealed record GeneralSettings : SettingsRoute;
@@ -629,11 +640,14 @@ public override Element Render()
629640

630641
// Serialization demo
631642
SubHeading("State Serialization").Margin(0, 24, 0, 0),
632-
TextBlock("Save and restore the entire navigation stack as JSON."),
643+
TextBlock("Save and restore the entire navigation stack. GetState/SetState " +
644+
"return a plain POCO; persist it however you like (here we use System.Text.Json)."),
633645
HStack(8,
634646
Button("Save State", () =>
635647
{
636-
var json = nav.GetState();
648+
var snapshot = nav.GetState();
649+
var json = System.Text.Json.JsonSerializer.Serialize(
650+
snapshot, NavDemoJsonContext.Default.NavigationStateAppRoute);
637651
Debug.WriteLine($"Navigation state: {json}");
638652
}),
639653
Button("Log Stack Info", () =>

skills/navigation.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,8 +233,21 @@ Caching preserves scroll position, text input, and component state.
233233
## 10. State serialization
234234

235235
```csharp
236-
var json = nav.GetState(); // serialize full nav state
237-
nav.SetState(json); // restore stacks + navigate
236+
var state = nav.GetState(); // NavigationState<TRoute> snapshot
237+
nav.SetState(state); // restore stacks + fire Navigated(Reset)
238+
```
239+
240+
Reactor returns a plain `NavigationState<TRoute>` POCO and lets you pick the
241+
storage format. For JSON persistence pair the snapshot with a
242+
`JsonSerializerContext` (AOT-safe):
243+
244+
```csharp
245+
[JsonSerializable(typeof(NavigationState<AppRoute>))]
246+
partial class AppJsonContext : JsonSerializerContext { }
247+
248+
var json = JsonSerializer.Serialize(nav.GetState(), AppJsonContext.Default.NavigationStateAppRoute);
249+
// later…
250+
nav.SetState(JsonSerializer.Deserialize(json, AppJsonContext.Default.NavigationStateAppRoute)!);
238251
```
239252

240253
Use for persisting navigation across app restarts.

src/Reactor/Controls/DataGrid/DataGridComponent.cs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Diagnostics.CodeAnalysis;
12
using Microsoft.UI.Reactor.Core;
23
using Microsoft.UI.Reactor.Data;
34
using Microsoft.UI.Reactor.Hooks;
@@ -24,7 +25,7 @@ namespace Microsoft.UI.Reactor.Controls;
2425
/// callback, same item count), and the Reactor reconciler only updates the cells whose
2526
/// output changed — as property updates on existing controls, not collection changes.
2627
/// </summary>
27-
public class DataGridComponent<T> : Component<DataGridElement<T>>
28+
public class DataGridComponent<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicConstructors)] T> : Component<DataGridElement<T>>
2829
{
2930
/// <summary>
3031
/// Input to the row-commit mutation. Bundles the row key, the post-edit item
@@ -45,9 +46,7 @@ public override Element Render()
4546
// affecting CellRenderers). Auto-columns are cached by UseMemo.
4647
var columns = el.Columns is not null
4748
? el.Columns
48-
#pragma warning disable IL2091 // Generic type parameter flows through without DynamicallyAccessedMembers annotation
4949
: UseMemo(() => Factories.AutoColumns<T>(registry, el.ColumnOverrides));
50-
#pragma warning restore IL2091
5150

5251
// Create the headless state machine once and hold it in a ref.
5352
var stateRef = UseRef<DataGridState<T>>(null!);

src/Reactor/Controls/DataGrid/DataGridElement.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Diagnostics.CodeAnalysis;
12
using Microsoft.UI.Reactor.Core;
23
using Microsoft.UI.Reactor.Data;
34
using Microsoft.UI.Reactor.Controls;
@@ -8,7 +9,7 @@ namespace Microsoft.UI.Reactor.Controls;
89
/// Element record for the DataGrid component.
910
/// Defines the full API surface for data grid rendering and interaction.
1011
/// </summary>
11-
public record DataGridElement<T> : Element
12+
public record DataGridElement<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicConstructors)] T> : Element
1213
{
1314
/// <summary>The data source providing rows.</summary>
1415
public required IDataSource<T> Source { get; init; }

0 commit comments

Comments
 (0)