Skip to content

Commit 378f8eb

Browse files
docs(getting-started): show launch line in samples + pipeline fixes (#331)
* docs(getting-started): show launch line in samples + pipeline fixes Getting-started samples now include the ReactorApp.Run<T>(...) call so readers see the full launch shape. Three companion pipeline fixes shake out from the same work: - Snippet extractor edits stayed off the table after a false start — the simpler fix was dropping the `#if DEBUG / preview: true / #endif` scaffolding from the doc apps and switching to the current `devtools: true` parameter (the older `preview:` is deprecated). A bullet in the template notes that real apps should guard `devtools` under `#if DEBUG`; we skip the conditional in samples for brevity. - NormalizeLineEndings in CompileCommand (also called by the reference generator) converts emitted Markdown to Environment.NewLine. Without it, every compile under Windows/`core.autocrlf=true` flapped all 63 outputs as modified. - reference-map.yaml gains a `Microsoft.UI.Reactor.Core.RenderContext.Use*` default so the actually-used hook surface (UseState, UseEffect, etc.) gets reference pages and `<!-- ref:UseState -->` markers in hooks.md resolve to working links. Six guide outputs (animation, architecture-overview, collections, effects, hooks, reconciliation) re-sync with their templates as a side effect. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(animation,collections): restore content lost from templates PR #322 (spec 042) wrote the "Transactional animation — Animations.Animate(...)" section into docs/guide/animation.md and the "Keyed reconciliation, in one paragraph" / IReactorKeyed / .WithKey(item) subsections into docs/guide/collections.md, but never added them to the .md.dt templates. The previous commit re-synced the generated outputs from their templates and stripped those sections. Port both back into the templates and regenerate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 96b4987 commit 378f8eb

47 files changed

Lines changed: 860 additions & 49 deletions

Some content is hidden

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

docs/_pipeline/ai-author-skill.md

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -336,15 +336,11 @@ using Microsoft.UI.Reactor.Core;
336336
using static Microsoft.UI.Reactor.Factories;
337337
using Microsoft.UI.Xaml;
338338

339-
ReactorApp.Run<MyApp>("Title", width: 600, height: 400
340-
#if DEBUG
341-
, preview: true
342-
#endif
343-
);
339+
ReactorApp.Run<MyApp>("Title", width: 600, height: 400, devtools: true);
344340
```
345341

346-
- Always include `preview: true` under `#if DEBUG` — this enables the
347-
screenshot capture system.
342+
- Always include `devtools: true` — this enables the screenshot capture system.
343+
(The older `preview:` parameter is deprecated; the compiler will warn.)
348344
- Each component class in the file can be wrapped in snippet markers.
349345
- The app should display a reasonable default state on launch (the screenshots
350346
are captured after `startup-delay` ms with no interaction).

docs/_pipeline/apps/calculator/App.cs

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,11 @@
1+
// <snippet:calculator-app>
12
using Microsoft.UI.Reactor;
23
using Microsoft.UI.Reactor.Core;
34
using static Microsoft.UI.Reactor.Factories;
45
using Microsoft.UI.Xaml;
56

6-
ReactorApp.Run<CalculatorApp>("Calculator", width: 380, height: 500
7-
#if DEBUG
8-
, preview: true
9-
#endif
10-
);
7+
ReactorApp.Run<CalculatorApp>("Calculator", width: 380, height: 500, devtools: true);
118

12-
// <snippet:calculator-app>
139
class CalculatorApp : Component
1410
{
1511
public override Element Render()

docs/_pipeline/apps/getting-started/App.cs

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,11 @@
1+
// <snippet:hello-world>
12
using Microsoft.UI.Reactor;
23
using Microsoft.UI.Reactor.Core;
34
using static Microsoft.UI.Reactor.Factories;
45
using Microsoft.UI.Xaml;
56

6-
ReactorApp.Run<GettingStartedApp>("Getting Started", width: 600, height: 400
7-
#if DEBUG
8-
, preview: true
9-
#endif
10-
);
7+
ReactorApp.Run<GettingStartedApp>("Getting Started", width: 600, height: 400, devtools: true);
118

12-
// <snippet:hello-world>
139
class GettingStartedApp : Component
1410
{
1511
public override Element Render()
@@ -24,7 +20,15 @@ public override Element Render()
2420
}
2521
// </snippet:hello-world>
2622

23+
// The classes below are alternate roots for the rest of this page. Only one
24+
// ReactorApp.Run<T>() call can run as a top-level statement, so the launch
25+
// lines for these classes are shown as comments — drop one into a fresh
26+
// App.cs to try it.
27+
2728
// <snippet:usestate-counter>
29+
// Launch with:
30+
// ReactorApp.Run<CounterExample>("Counter", width: 600, height: 400);
31+
2832
class CounterExample : Component
2933
{
3034
public override Element Render()
@@ -44,6 +48,9 @@ public override Element Render()
4448
// </snippet:usestate-counter>
4549

4650
// <snippet:layout-basics>
51+
// Launch with:
52+
// ReactorApp.Run<LayoutBasicsExample>("Layout", width: 600, height: 400);
53+
4754
class LayoutBasicsExample : Component
4855
{
4956
public override Element Render()
@@ -77,6 +84,9 @@ public override Element Render()
7784
// </snippet:layout-basics>
7885

7986
// <snippet:multiple-state>
87+
// Launch with:
88+
// ReactorApp.Run<MultipleStateExample>("Multiple State", width: 600, height: 400);
89+
8090
class MultipleStateExample : Component
8191
{
8292
public override Element Render()

docs/_pipeline/apps/todo-app/App.cs

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,11 @@
1+
// <snippet:todo-app>
12
using Microsoft.UI.Reactor;
23
using Microsoft.UI.Reactor.Core;
34
using static Microsoft.UI.Reactor.Factories;
45
using Microsoft.UI.Xaml;
56

6-
ReactorApp.Run<TodoApp>("Todo App", width: 550, height: 600
7-
#if DEBUG
8-
, preview: true
9-
#endif
10-
);
11-
12-
// <snippet:todo-record>
13-
record TodoItem(string Text, bool Done);
14-
// </snippet:todo-record>
7+
ReactorApp.Run<TodoApp>("Todo App", width: 550, height: 600, devtools: true);
158

16-
// <snippet:todo-app>
179
class TodoApp : Component
1810
{
1911
public override Element Render()
@@ -81,3 +73,7 @@ public override Element Render()
8173
}
8274
}
8375
// </snippet:todo-app>
76+
77+
// <snippet:todo-record>
78+
record TodoItem(string Text, bool Done);
79+
// </snippet:todo-record>

docs/_pipeline/reference-map.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
# fall through with no category/guide-pages.
1414

1515
defaults:
16+
- match: "Microsoft.UI.Reactor.Core.RenderContext.Use*"
17+
category: hooks
18+
guide-pages: [hooks, effects]
1619
- match: "Microsoft.UI.Reactor.Hooks.*"
1720
category: hooks
1821
guide-pages: [hooks, effects]

docs/_pipeline/templates/animation.md.dt

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,89 @@ animation runs automatically when the reconciler detects the transition.
162162
Use connected animations for list-to-detail [navigation](navigation.md)
163163
where an element "flies" from the list into the detail view.
164164

165+
## Transactional animation — `Animations.Animate(...)`
166+
167+
`Animations.Animate(kind, action)` is Reactor's SwiftUI-style transactional
168+
animation primitive. Wrap a state mutation, and any **structural** change to
169+
a keyed list (insert, move, remove) that comes out of that mutation picks up
170+
the kind — without a per-element modifier in sight.
171+
172+
```csharp
173+
class TodoList : Component
174+
{
175+
public override Element Render()
176+
{
177+
var (items, setItems) = UseState<IReadOnlyList<Todo>>(_seed);
178+
179+
return VStack(12,
180+
Button("Add", () =>
181+
Animations.Animate(AnimationKind.Spring, () =>
182+
setItems([.. items, new Todo(Guid.NewGuid().ToString(), "New")]))),
183+
ListView<Todo>(items, (t, _) => TextBlock(t.Title).Padding(8))
184+
.Height(400)
185+
);
186+
}
187+
}
188+
```
189+
190+
`AnimationKind` is the declarative knob — `Spring`, `EaseIn`, `EaseOut`,
191+
`EaseInOut`, `Default`, or `None`. The kind flows through an `AsyncLocal`
192+
ambient: a setter invoked inside `Animate` snapshots the ambient before
193+
queuing the render, and the reconciler re-pushes the snapshot around the
194+
diff pass so `ListView`, `GridView`, `LazyVStack` (and hand-built
195+
`FlexColumn(items.Select(...).WithKey(...))` children) all animate the
196+
resulting insert / move / remove. (See spec 042 §6.)
197+
198+
### What `Animate` does *not* do
199+
200+
`Animate` is **scoped to structural changes**. A leaf `TextBlock` whose
201+
`Foreground` changes inside `Animate(.Spring)` does **not** animate the
202+
foreground — that remains the job of per-element modifiers like
203+
`.WithImplicitTransition(...)` or
204+
[`AnimationScope.WithAnimation(...)`](#withanimation-scope). The two
205+
channels are deliberately independent so the SwiftUI "`withAnimation` only
206+
animates layout-shape ops" contract holds; conflating them would surprise
207+
users coming from that mental model.
208+
209+
Per-element animation modifiers continue to win when set: declaring
210+
`.Transition(Fade)` on a row makes that row's enter / exit use Fade
211+
regardless of the ambient. The ambient is a default for the transactional
212+
case, not a hammer for every change.
213+
214+
### Nesting and explicit suppression
215+
216+
Nested `Animate` calls stack like `using` blocks — the inner kind wins for
217+
state changes inside its scope, and the outer kind resumes after:
218+
219+
```csharp
220+
Animations.Animate(AnimationKind.Spring, () =>
221+
{
222+
// Insert: animates with Spring.
223+
setItems([.. items, x]);
224+
225+
Animations.Animate(AnimationKind.None, () =>
226+
{
227+
// Insert inside None: no animation, even though we're still inside
228+
// an outer Spring transaction. Useful when a child component needs
229+
// to opt out of the caller's implicit animation intent.
230+
setOtherItems([.. others, y]);
231+
});
232+
});
233+
```
234+
235+
### Reduced motion
236+
237+
`Animate` respects the system's reduced-motion preference *at the call
238+
site*. Read `UseReducedMotion()` and skip the wrapper when the user has
239+
opted out:
240+
241+
```csharp
242+
var reduceMotion = UseReducedMotion();
243+
Action commit = () => setItems([.. items, x]);
244+
if (reduceMotion) commit();
245+
else Animations.Animate(AnimationKind.Spring, commit);
246+
```
247+
165248
## WithAnimation Scope
166249

167250
`AnimationScope.WithAnimation()` wraps a state change so that every

docs/_pipeline/templates/collections.md.dt

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,61 @@ The `keySelector` parameter (`c => c.Id`) tells Reactor how to identify each
5959
item. When your data changes, Reactor uses keys to match old items to new ones
6060
and update only what changed — no full-list rebuild.
6161

62+
### Keyed reconciliation, in one paragraph
63+
64+
When you replace the list (immutable state in, immutable state out), Reactor
65+
walks the old and new key sequences and emits the minimum set of
66+
`Insert` / `Move` / `RemoveAt` operations to the underlying WinUI
67+
`ListView` / `GridView` / `ItemsRepeater`. A single insert at the front of
68+
a 100-item list animates one row instead of re-realizing 100 containers.
69+
You get this for free as long as `keySelector` returns a value that is:
70+
71+
- **Stable** across re-renders for the lifetime of the item — using a
72+
row index defeats reconciliation and produces the same churn as
73+
having no key.
74+
- **Unique** within the list — duplicate keys trigger a bulk-replace
75+
bailout and a one-shot diagnostic in the dev log.
76+
- **Non-null** — null keys bail out the diff for the affected list.
77+
78+
### `IReactorKeyed` — identity on the data
79+
80+
When a model type owns its identity, implement `IReactorKeyed` to drop the
81+
`keySelector` boilerplate at every call site:
82+
83+
```csharp
84+
record Contact(string Id, string Name, string Email) : IReactorKeyed
85+
{
86+
string IReactorKeyed.Key => Id;
87+
}
88+
89+
// keySelector is inferred from IReactorKeyed.Key:
90+
ListView<Contact>(contacts, (contact, index) => …);
91+
LazyVStack<Contact>(contacts, (contact, index) => …);
92+
GridView<Contact>(contacts, (contact, index) => …);
93+
```
94+
95+
The explicit-`keySelector` overload remains the right choice for types you
96+
do not own (interop / third-party POCOs without a natural identity
97+
property) — for those, keep `c => c.Id` at the call site.
98+
99+
### `.WithKey(item)` for hand-built children
100+
101+
For hand-built keyed children — `FlexColumn(items.Select(…))` and
102+
similar — `.WithKey<TKey>(TKey item) where TKey : IReactorKeyed` is the
103+
ergonomic peer of `.WithKey(item.Key)`:
104+
105+
```csharp
106+
FlexColumn(
107+
contacts.Select(c =>
108+
TextBlock(c.Name).WithKey(c) // identity-on-data
109+
).ToArray<Element?>()
110+
)
111+
```
112+
113+
Both shapes route through the same incremental diff, so a hand-built
114+
`FlexColumn` of contacts animates inserts and reorders just like the
115+
templated `ListView<Contact>`.
116+
62117
## LazyVStack (Virtualized)
63118

64119
`LazyVStack<T>` looks like `ListView<T>` but only creates elements for items

docs/_pipeline/templates/getting-started.md.dt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,9 @@ Run it with `dotnet run` and you'll see this:
152152
Here's what's happening:
153153

154154
- **`ReactorApp.Run<T>`** launches a window and mounts your root component.
155+
- **`devtools: true`** enables the in-app dev menu and screenshot capture. In a
156+
real app you'd normally guard this under `#if DEBUG` so release builds don't
157+
ship the dev surface; we skip the conditional here for brevity.
155158
- **[`UseState`](hooks.md)** returns the current value and a setter. When you call the
156159
setter, Reactor re-renders the component with the new value.
157160
- **[`VStack`](layout.md)** stacks children vertically. The number `16` is the pixel spacing.

docs/guide/architecture-overview.md

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,14 +119,80 @@ sees one shape per concept.
119119
```csharp
120120
public UIElement GetElement(ElementFactoryGetArgs args)
121121
{
122-
var index = args.Data is int i ? i : 0;
122+
// Resolve the realized data → (key, dataIndex). Three paths:
123+
// 1. Spec 042: args.Data is ReactorRow — read both off the row.
124+
// 2. Legacy: args.Data is int — index directly, synthetic key.
125+
// 3. Fallback: unknown shape, treat as index 0.
126+
string key;
127+
int index;
128+
switch (args.Data)
129+
{
130+
case ReactorRow row:
131+
key = row.Key;
132+
index = row.Index;
133+
break;
134+
case int i:
135+
index = i;
136+
key = i.ToString(global::System.Globalization.CultureInfo.InvariantCulture);
137+
break;
138+
default:
139+
index = 0;
140+
key = "0";
141+
break;
142+
}
143+
123144
if (index < 0 || index >= _items.Count)
124145
return new TextBlock { Text = "" };
125146

126147
var item = _items[index];
127148
var element = _viewBuilder(item, index);
128-
_mountedElements[index] = element;
129-
var control = _reconciler.Mount(element, _requestRerender);
149+
150+
UIElement? control;
151+
if (_recyclePool.Count > 0)
152+
{
153+
// Reuse a previously-recycled container. The framework still has
154+
// it parented to the ItemsRepeater, so the ViewManager.cpp:866
155+
// Append-skip kicks in and the visual tree stays stable.
156+
var reused = _recyclePool.Pop();
157+
if (_lastElementByControl.TryGetValue(reused, out var oldElement))
158+
{
159+
var replacement = _reconciler.Reconcile(oldElement, element, reused, _requestRerender);
160+
if (replacement is not null && !ReferenceEquals(replacement, reused))
161+
{
162+
// Heterogeneous-row case: Reconcile decided the root
163+
// element type changed and built a fresh control.
164+
// `reused` is now unmounted but still parented to the
165+
// ItemsRepeater — detach so it doesn't sit there as
166+
// an orphan (the original leak shape we're fixing).
167+
// (PR #324 review)
168+
DetachFromParent(reused);
169+
_lastElementByControl.Remove(reused);
170+
control = replacement;
171+
}
172+
else
173+
{
174+
control = reused;
175+
}
176+
}
177+
else
178+
{
179+
// Defensive: pool entry without a tracked oldElement should
180+
// not happen — fall back to re-mounting on top of it.
181+
control = _reconciler.Mount(element, _requestRerender);
182+
}
183+
}
184+
else
185+
{
186+
control = _reconciler.Mount(element, _requestRerender);
187+
}
188+
189+
_mountedElements[key] = element;
190+
if (control is not null)
191+
{
192+
_keyByControl[control] = key;
193+
_lastElementByControl[control] = element;
194+
}
195+
130196
return control ?? new TextBlock { Text = "" };
131197
}
132198
```

docs/guide/effects.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
Effects are commit-phase side effects with dependency tracking. After Reactor
33
finishes [reconciling](reconciliation.md) the element tree and patching the
44
WinUI controls for a render, it walks the effect queue for that render and
5-
runs every <!-- ref:UseEffect --> body whose dependency array has changed
5+
runs every [UseEffect](reference/hooks/UseEffect.md) body whose dependency array has changed
66
since the previous commit. The body runs **after** the new UI has been
77
committed — never during render — so an effect is the safe place to start a
88
timer, open a subscription, or kick off a `Task.Run` that will eventually

0 commit comments

Comments
 (0)