diff --git a/src/AcDream.App/UI/Layout/SocialFriendsPageController.cs b/src/AcDream.App/UI/Layout/SocialFriendsPageController.cs
index 86a18ae3..4226d310 100644
--- a/src/AcDream.App/UI/Layout/SocialFriendsPageController.cs
+++ b/src/AcDream.App/UI/Layout/SocialFriendsPageController.cs
@@ -9,8 +9,10 @@ namespace AcDream.App.UI.Layout;
/// only, bound directly to the J4.1 owner
/// (RuntimeCommunicationState.Friends). Rebuilds on
/// change; polled every frame from
-/// (cheap — a single
-/// comparison when nothing changed).
+/// WHILE THE PANEL IS VISIBLE
+/// (cheap — a single comparison when nothing
+/// changed; see the scrollbar/rebuild-discipline paragraph below for the
+/// fix-round visibility gate).
///
///
/// D1 — inert actions. This campaign implements no Friends
@@ -20,9 +22,10 @@ namespace AcDream.App.UI.Layout;
/// "Appear Offline" checkbox (0x1000052C) are left AUTHORED and
/// CLICKABLE with no handler — already
/// builds every button/checkbox with a null OnClick by default, so
-/// no explicit "leave unbound" code is needed here. See this commit's
-/// single register row covering the whole D1 Friends/Squelch inert-actions
-/// scope (one row, not one per button).
+/// no explicit "leave unbound" code is needed here. See register row
+/// AD-79 (docs/architecture/retail-divergence-register.md), which
+/// covers the whole D1 Friends/Squelch inert-actions scope (one row, not
+/// one per button).
///
///
///
@@ -31,11 +34,39 @@ namespace AcDream.App.UI.Layout;
/// name-text leaf — see that class's doc for why the deepest match is used
/// (gmFriendsUI is outside this campaign's decompiled scope).
///
+///
+///
+/// Scrollbar (fix-round blast MF-1). The ListBox 0x10000517
+/// authors a direct sibling scrollbar, 0x10000518 — wired to
+/// the SAME way every other
+/// UiTemplateListBox consumer wires its own scrollbar
+/// (CharacterOptionsPageController/ChatOptionsPageController/
+/// ConfigOptionsPageController/KeyboardConfigController).
+/// Without it the list has NO wheel fallback (
+/// has no wheel handler) and is completely unreachable past the box's
+/// visible extent.
+///
+///
+///
+/// Rebuild discipline (fix-round blast SF-2/SF-3).
+/// only advances after a successful rebuild — a
+/// transient resolver miss (
+/// returning null) leaves the revision UNCONSUMED so the next
+/// retries instead of latching an empty list until the
+/// NEXT server-side change. also gates
+/// this controller's on the panel's own visibility (its
+/// IRetainedPanelController.OnShown/OnHidden hooks) — the
+/// per-row template resolve takes the shared DAT lock, so no rebuild work
+/// runs while the panel is closed.
+///
///
public sealed class SocialFriendsPageController
{
private const uint ListBoxId = 0x10000517u;
+ /// The ListBox's own linked scrollbar (see class doc).
+ private const uint ScrollbarElementId = 0x10000518u;
+
private readonly UiTemplateListBox _listBox;
private readonly FriendsState _friends;
private long _lastRevision = long.MinValue;
@@ -64,6 +95,13 @@ public sealed class SocialFriendsPageController
}
listBox.TemplateResolver = templateResolver;
+ if (UiElement.FindDescendant(pageRoot, ScrollbarElementId) is UiScrollbar scrollbar)
+ scrollbar.Model = listBox.Scroll;
+ else
+ Console.WriteLine(
+ $"[D.2b] SocialFriendsPageController: scrollbar 0x{ScrollbarElementId:X8} "
+ + "not found — the Friends list will not scroll.");
+
var controller = new SocialFriendsPageController(listBox, friends);
controller.Refresh();
return controller;
@@ -78,17 +116,21 @@ public sealed class SocialFriendsPageController
private void Refresh()
{
- _lastRevision = _friends.Revision;
+ long revision = _friends.Revision;
_listBox.Flush();
+ bool allRowsResolved = true;
foreach (FriendEntry friend in _friends.Snapshot())
{
UiElement? row = _listBox.AddItemFromTemplateList(0);
- if (row is null) continue;
+ if (row is null) { allRowsResolved = false; continue; }
if (SocialPanelRowText.FindDeepest(row) is { } text)
{
string name = friend.Name;
text.LinesProvider = () => [new UiText.Line(name, Vector4.One)];
}
}
+ // SF-3: only latch the revision once the rebuild actually reflects it —
+ // a resolver miss must not silently swallow a future revision bump.
+ if (allRowsResolved) _lastRevision = revision;
}
}
diff --git a/src/AcDream.App/UI/Layout/SocialPanelController.cs b/src/AcDream.App/UI/Layout/SocialPanelController.cs
index 41326f65..b0e8ad32 100644
--- a/src/AcDream.App/UI/Layout/SocialPanelController.cs
+++ b/src/AcDream.App/UI/Layout/SocialPanelController.cs
@@ -198,27 +198,72 @@ public sealed class SocialPanelController : IRetainedPanelController
public void ShowFellowship() => _tabPanel.SwitchTo(FellowshipPageId);
/// True when the Allegiance tab is the active page — lets
- /// implement retail's
- /// Toggle-action close-on-second-press semantics (same shape as
- /// OpenSpellbook's own page-aware toggle).
+ /// implement the
+ /// close-on-second-press-of-the-SAME-tab semantics every other
+ /// Toggle*Panel action already uses (OpenSpellbook's own
+ /// page-aware toggle is the precedent this follows). Fix-round
+ /// mechanism SF-1: this is acdream's OWN convention, not a ported
+ /// retail behavior — no 0x1000000E/0x1000000F
+ /// OnAction consumer exists anywhere in the binary (searched
+ /// exhaustively: no GetAttribute_Enum(this, 0x57, …) site,
+ /// ClientUISystem::OnAction's m_InputAction > 0x7c
+ /// branch only handles three unrelated ids, and gmPanelUI's
+ /// global-message listener is COMDAT-folded onto a no-op), so what a
+ /// repeat F3/F4 press does when the panel is already open on the OTHER
+ /// tab is not established from retail decomp — see
+ /// docs/research/2026-08-11-fa-panel-structure.md §8 unknown
+ /// U11.
public bool IsShowingAllegiance => _tabPanel.ActivePageElementId == AllegiancePageId;
/// True when the Fellowship tab is the active page.
public bool IsShowingFellowship => _tabPanel.ActivePageElementId == FellowshipPageId;
+ /// True while the social panel's own window is shown — set by
+ /// /. Fix-round blast SF-2:
+ /// gates the Friends/Squelch rebuild (see ) so their
+ /// DAT-locked row-template resolve never runs while the panel is
+ /// closed.
+ private bool _visible;
+
+ /// hook, fired by the
+ /// window manager on every hidden-to-shown transition (fix-round blast
+ /// SF-2). Does not force an immediate Friends/Squelch rebuild — the
+ /// next naturally picks up any revision bump that
+ /// accumulated while hidden.
+ public void OnShown() => _visible = true;
+
+ /// hook, fired on every
+ /// shown-to-hidden transition (fix-round blast SF-2).
+ public void OnHidden() => _visible = false;
+
///
/// Per-frame poll: the Fellowship/Allegiance empty-state gates (no
/// data-driven provider exists) and the
/// Friends/Squelch read-only list rebuilds (revision-gated — cheap when
/// nothing changed). Called from
/// alongside every other retained panel's own Tick().
+ ///
+ ///
+ /// Fix-round blast SF-2. The Friends/Squelch rebuild
+ /// (/
+ /// ) takes the shared DAT
+ /// lock per row template resolve — it only runs while
+ /// is true. Fellowship/Allegiance stay unconditional: their own
+ /// Tick() methods are two bool/delegate writes with no DAT
+ /// access, cheap enough to keep polling every frame the way every other
+ /// retained panel's empty-state gate already does.
+ ///
///
public void Tick()
{
+ if (_disposed) return;
_fellowship?.Tick();
_allegiance?.Tick();
- _friends?.Tick();
- _squelch?.Tick();
+ if (_visible)
+ {
+ _friends?.Tick();
+ _squelch?.Tick();
+ }
}
public void Dispose()
diff --git a/src/AcDream.App/UI/Layout/SocialSquelchPageController.cs b/src/AcDream.App/UI/Layout/SocialSquelchPageController.cs
index 6bbc98ad..6a010521 100644
--- a/src/AcDream.App/UI/Layout/SocialSquelchPageController.cs
+++ b/src/AcDream.App/UI/Layout/SocialSquelchPageController.cs
@@ -12,14 +12,16 @@ namespace AcDream.App.UI.Layout;
/// character's own display name) and squelched accounts
/// (, whose dictionary KEY is the
/// account name itself). Rebuilds on
-/// change; polled every frame from .
+/// change; polled every frame from
+/// WHILE THE PANEL IS VISIBLE (see the rebuild-discipline paragraph below).
///
///
/// D1 — inert actions. Same disposition as
/// : the three buttons
/// (0x10000547/0x1000054B/0x1000054C) are left
-/// AUTHORED and CLICKABLE with no handler. See this commit's single
-/// register row covering the whole D1 Friends/Squelch inert-actions scope.
+/// AUTHORED and CLICKABLE with no handler. See register row AD-79
+/// (docs/architecture/retail-divergence-register.md), which covers
+/// the whole D1 Friends/Squelch inert-actions scope.
///
///
///
@@ -27,11 +29,32 @@ namespace AcDream.App.UI.Layout;
/// see for the name-text resolution note
/// (gmSquelchUI is outside this campaign's decompiled scope).
///
+///
+///
+/// Scrollbar (fix-round blast MF-1). The ListBox 0x1000053E
+/// authors a direct sibling scrollbar, 0x10000543 — wired to
+/// the same way
+/// wires its own (see that
+/// class's doc for the full rationale, including why there is no wheel
+/// fallback).
+///
+///
+///
+/// Rebuild discipline (fix-round blast SF-2/SF-3). Same discipline as
+/// : only
+/// advances after every row resolves, and
+/// gates on the
+/// panel's own visibility so no DAT-locked rebuild work runs while the
+/// panel is closed.
+///
///
public sealed class SocialSquelchPageController
{
private const uint ListBoxId = 0x1000053Eu;
+ /// The ListBox's own linked scrollbar (see class doc).
+ private const uint ScrollbarElementId = 0x10000543u;
+
private readonly UiTemplateListBox _listBox;
private readonly SquelchState _squelch;
private long _lastRevision = long.MinValue;
@@ -60,6 +83,13 @@ public sealed class SocialSquelchPageController
}
listBox.TemplateResolver = templateResolver;
+ if (UiElement.FindDescendant(pageRoot, ScrollbarElementId) is UiScrollbar scrollbar)
+ scrollbar.Model = listBox.Scroll;
+ else
+ Console.WriteLine(
+ $"[D.2b] SocialSquelchPageController: scrollbar 0x{ScrollbarElementId:X8} "
+ + "not found — the Squelch list will not scroll.");
+
var controller = new SocialSquelchPageController(listBox, squelch);
controller.Refresh();
return controller;
@@ -74,21 +104,27 @@ public sealed class SocialSquelchPageController
private void Refresh()
{
- _lastRevision = _squelch.Revision;
+ long revision = _squelch.Revision;
_listBox.Flush();
SquelchDatabase database = _squelch.Snapshot();
+ bool allRowsResolved = true;
foreach (SquelchInfo character in database.Characters.Values)
- AddRow(character.Name);
+ allRowsResolved &= AddRow(character.Name);
foreach (string accountName in database.Accounts.Keys)
- AddRow(accountName);
+ allRowsResolved &= AddRow(accountName);
+
+ // SF-3: only latch the revision once the rebuild actually reflects it —
+ // a resolver miss must not silently swallow a future revision bump.
+ if (allRowsResolved) _lastRevision = revision;
}
- private void AddRow(string name)
+ private bool AddRow(string name)
{
UiElement? row = _listBox.AddItemFromTemplateList(0);
- if (row is null) return;
+ if (row is null) return false;
if (SocialPanelRowText.FindDeepest(row) is { } text)
text.LinesProvider = () => [new UiText.Line(name, Vector4.One)];
+ return true;
}
}
diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs
index dc491450..cd7c005c 100644
--- a/src/AcDream.App/UI/RetailUiRuntime.cs
+++ b/src/AcDream.App/UI/RetailUiRuntime.cs
@@ -2618,26 +2618,50 @@ public sealed class RetailUiRuntime : IDisposable
return;
}
- // The Friends/Squelch row templates (0x2100005D/0x21000060) are
- // resolved lazily, per row — the SAME "second dat-lock scope per
- // resolve" shape the Character/Chat/Config tab row templates use
- // (MountOptionsPanel, above), since each row's own template is a
- // live DAT read.
+ // The Friends/Squelch row templates (0x2100005D/0x21000060) differ
+ // from the Character/Chat/Config tab row templates: those build a
+ // FIXED row set exactly once at Bind (MountOptionsPanel, above) and
+ // never revisit their resolver again. Friends/Squelch instead poll
+ // a live roster whose COUNT can change all session (a friend logs
+ // in/out), so this resolver is called on every rebuild.
+ //
+ // Fix-round blast SF-2: the ORIGINAL shape re-ran
+ // LayoutImporter.ImportInfos — a full DAT tree walk — under the
+ // shared DatLock on EVERY row, every revision, even while the
+ // social panel was closed (SocialPanelController.Tick had no
+ // visibility gate). The fix has two parts: this resolver now caches
+ // each template's ElementInfo the FIRST time it is resolved (i.e.
+ // once, effectively at Bind — the page controllers' own Bind()
+ // calls Refresh() once unconditionally) and never re-Imports for
+ // that template id again; SocialPanelController separately gates
+ // Friends/Squelch's Tick-driven rebuild on the panel's own
+ // visibility (see that class's OnShown/OnHidden), so no rebuild —
+ // cached or not — runs while the panel is closed. Build() itself
+ // still runs (and still needs the DatLock: its sprite/font/string
+ // resolvers read the shared, non-thread-safe DatCollection) because
+ // each row needs its OWN UiElement instance — but the expensive
+ // per-row DAT tree walk is gone after the first resolve.
+ var rowTemplateCache = new Dictionary<(uint LayoutId, uint ElementId), ElementInfo?>();
UiElement? TemplateResolver(uint templateLayoutId, uint templateElementId)
{
lock (_bindings.Assets.DatLock)
{
+ var key = (templateLayoutId, templateElementId);
+ if (!rowTemplateCache.TryGetValue(key, out ElementInfo? info))
+ {
+ info = LayoutImporter.ImportInfos(
+ _bindings.Assets.Dats, templateLayoutId, templateElementId);
+ rowTemplateCache[key] = info;
+ }
+ if (info is null) return null;
+
var strings = new DatStringResolver(_bindings.Assets.Dats);
- ElementInfo? info = LayoutImporter.ImportInfos(
- _bindings.Assets.Dats, templateLayoutId, templateElementId);
- return info is null
- ? null
- : LayoutImporter.Build(
- info,
- _bindings.Assets.ResolveSprite,
- _bindings.Assets.DefaultFont,
- _bindings.Assets.ResolveFont,
- strings.Resolve).Root;
+ return LayoutImporter.Build(
+ info,
+ _bindings.Assets.ResolveSprite,
+ _bindings.Assets.DefaultFont,
+ _bindings.Assets.ResolveFont,
+ strings.Resolve).Root;
}
}
diff --git a/tests/AcDream.App.Tests/UI/Layout/SocialPanelControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SocialPanelControllerTests.cs
index ebd53ee2..ff7391c0 100644
--- a/tests/AcDream.App.Tests/UI/Layout/SocialPanelControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/SocialPanelControllerTests.cs
@@ -34,13 +34,16 @@ public sealed class SocialPanelControllerTests
}
/// Mirrors the real Friends/Squelch row templates' own two-deep
- /// nested-text shape (FA3 live-mount probe) so
+ /// nested-text shape AND their own authored extent (270x24, mechanism
+ /// SF-9's live dump) — FA3 live-mount probe — so
/// has something realistic
- /// to resolve, without needing the templates' own (uncommitted, separate
+ /// to resolve and grows
+ /// realistically per row (blast MF-1's long-roster scroll tests),
+ /// without needing the templates' own (uncommitted, separate
/// LayoutDesc) fixtures.
private static UiElement? FakeRowTemplateResolver(uint layoutId, uint elementId)
{
- var outer = new UiText();
+ var outer = new UiText { Width = 270f, Height = 24f };
var inner = new UiText();
outer.AddChild(inner);
return outer;
@@ -228,7 +231,13 @@ public sealed class SocialPanelControllerTests
layout, MakeCallbacks(friends: friends));
Assert.NotNull(controller);
- UiElement friendsPage = UiElement.FindDescendant(controller!.TabPanel, 0x10000513u)!;
+ // Fix-round blast SF-2: Friends/Squelch rebuild is gated on the
+ // panel's own visibility — simulate the window being shown, the
+ // same IRetainedPanelController.OnShown() the window manager calls
+ // in production.
+ controller!.OnShown();
+
+ UiElement friendsPage = UiElement.FindDescendant(controller.TabPanel, 0x10000513u)!;
var listBox = Assert.IsType(
UiElement.FindDescendant(friendsPage, 0x10000517u));
Assert.Equal(0, listBox.ViewportForTest?.Children.Count ?? 0);
@@ -269,6 +278,14 @@ public sealed class SocialPanelControllerTests
// ── D1: Friends/Squelch action buttons are honest INERT ────────────────
+ ///
+ /// Pins the INERT contract register row AD-79 enumerates. Fix-round
+ /// mechanism SF-5: AD-79 names SEVEN controls (three Friends buttons,
+ /// the Friends "Appear Offline"-shaped checkbox, three Squelch
+ /// buttons) but this test previously only covered the six buttons —
+ /// 0x1000052C builds through
+ /// as a too, so it slots into the same loop.
+ ///
[Fact]
public void FriendsAndSquelchActionButtons_AreClickable_ButHaveNoHandler()
{
@@ -276,7 +293,7 @@ public sealed class SocialPanelControllerTests
SocialPanelController? controller = SocialPanelController.Bind(layout, MakeCallbacks());
Assert.NotNull(controller);
- foreach (uint buttonId in new[] { 0x10000514u, 0x10000515u, 0x10000516u })
+ foreach (uint buttonId in new[] { 0x10000514u, 0x10000515u, 0x10000516u, 0x1000052Cu })
{
var button = Assert.IsType(
UiElement.FindDescendant(controller!.TabPanel, buttonId));
@@ -289,4 +306,155 @@ public sealed class SocialPanelControllerTests
Assert.Null(button.OnClick);
}
}
+
+ // ── Scrollbar wiring (blast MF-1) ───────────────────────────────────────
+
+ [Fact]
+ public void Friends_ScrollbarModel_IsWiredToListBoxScroll()
+ {
+ ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
+ SocialPanelController? controller = SocialPanelController.Bind(layout, MakeCallbacks());
+ Assert.NotNull(controller);
+
+ UiElement friendsPage = UiElement.FindDescendant(controller!.TabPanel, 0x10000513u)!;
+ var listBox = Assert.IsType(
+ UiElement.FindDescendant(friendsPage, 0x10000517u));
+ var scrollbar = Assert.IsType(
+ UiElement.FindDescendant(friendsPage, 0x10000518u));
+
+ Assert.Same(listBox.Scroll, scrollbar.Model);
+ }
+
+ [Fact]
+ public void Squelch_ScrollbarModel_IsWiredToListBoxScroll()
+ {
+ ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
+ SocialPanelController? controller = SocialPanelController.Bind(layout, MakeCallbacks());
+ Assert.NotNull(controller);
+
+ UiElement squelchPage = UiElement.FindDescendant(controller!.TabPanel, 0x1000054Au)!;
+ var listBox = Assert.IsType(
+ UiElement.FindDescendant(squelchPage, 0x1000053Eu));
+ var scrollbar = Assert.IsType(
+ UiElement.FindDescendant(squelchPage, 0x10000543u));
+
+ Assert.Same(listBox.Scroll, scrollbar.Model);
+ }
+
+ ///
+ /// Blast MF-1: proves a roster taller than the panel is actually
+ /// REACHABLE through the scrollbar, not merely wired-but-unused. 40 rows
+ /// at the real Friends row template's own height (270x24, mechanism
+ /// SF-9's live dump) comfortably exceed the ListBox's authored extent
+ /// (270x400) — this roster genuinely needs scrolling.
+ ///
+ [Fact]
+ public void Friends_LongRoster_IsReachableViaScrollbar()
+ {
+ var friends = new FriendsState();
+ var entries = new List();
+ for (uint i = 0; i < 40; i++)
+ entries.Add(new(i, $"Friend{i}", true, false, System.Array.Empty(), System.Array.Empty()));
+ friends.Apply(new FriendsUpdate(FriendsUpdateType.Full, entries));
+
+ ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
+ SocialPanelController? controller = SocialPanelController.Bind(
+ layout, MakeCallbacks(friends: friends));
+ Assert.NotNull(controller);
+
+ UiElement friendsPage = UiElement.FindDescendant(controller!.TabPanel, 0x10000513u)!;
+ var listBox = Assert.IsType(
+ UiElement.FindDescendant(friendsPage, 0x10000517u));
+ var scrollbar = Assert.IsType(
+ UiElement.FindDescendant(friendsPage, 0x10000518u));
+
+ Assert.True(
+ listBox.ContentHeight > (int)listBox.Height,
+ $"40 rows (contentHeight={listBox.ContentHeight}) should exceed the box's own height ({listBox.Height})");
+
+ // The scrollbar's Model IS the ListBox's own Scroll — moving it
+ // moves the SAME state that positions the rows
+ // (UiScrollablePanel.LayoutScrollableChildren, driven by the real
+ // render loop; simulated directly here since this is a dat-free
+ // controller test with no Draw pass).
+ scrollbar.Model!.SetExtents(listBox.ContentHeight, (int)listBox.Height);
+ Assert.True(scrollbar.Model.HasOverflow);
+ int before = scrollbar.Model.ScrollY;
+ scrollbar.Model.ScrollByLines(4);
+ Assert.True(scrollbar.Model.ScrollY > before);
+ }
+
+ /// Squelch counterpart of
+ /// (270x430 authored extent).
+ [Fact]
+ public void Squelch_LongRoster_IsReachableViaScrollbar()
+ {
+ var characters = new Dictionary();
+ for (uint i = 0; i < 40; i++)
+ characters[i] = new SquelchInfo($"Grief{i}", false, new HashSet());
+ var squelch = new SquelchState();
+ squelch.Replace(new SquelchDatabase(
+ new Dictionary(System.StringComparer.OrdinalIgnoreCase),
+ characters,
+ new SquelchInfo(string.Empty, false, new HashSet())));
+
+ ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
+ SocialPanelController? controller = SocialPanelController.Bind(
+ layout, MakeCallbacks(squelch: squelch));
+ Assert.NotNull(controller);
+
+ UiElement squelchPage = UiElement.FindDescendant(controller!.TabPanel, 0x1000054Au)!;
+ var listBox = Assert.IsType(
+ UiElement.FindDescendant(squelchPage, 0x1000053Eu));
+ var scrollbar = Assert.IsType(
+ UiElement.FindDescendant(squelchPage, 0x10000543u));
+
+ Assert.True(
+ listBox.ContentHeight > (int)listBox.Height,
+ $"40 rows (contentHeight={listBox.ContentHeight}) should exceed the box's own height ({listBox.Height})");
+
+ scrollbar.Model!.SetExtents(listBox.ContentHeight, (int)listBox.Height);
+ Assert.True(scrollbar.Model.HasOverflow);
+ int before = scrollbar.Model.ScrollY;
+ scrollbar.Model.ScrollByLines(4);
+ Assert.True(scrollbar.Model.ScrollY > before);
+ }
+
+ // ── Rebuild discipline (blast SF-2/SF-3) ────────────────────────────────
+
+ ///
+ /// Blast SF-2: the panel's Friends/Squelch rebuild must not run while
+ /// the window is closed. starts
+ /// with no visibility notification (matches production: the window
+ /// mounts Visible = false and nothing calls OnShown until
+ /// F3/F4), so a revision bump with no OnShown call must NOT
+ /// rebuild the list; the SAME bump picked up on the next Tick()
+ /// after OnShown() proves the rebuild is deferred, not lost.
+ ///
+ [Fact]
+ public void Friends_RevisionBumpWhileHidden_DoesNotRebuild_ButRebuildsOnShow()
+ {
+ var friends = new FriendsState();
+ ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
+ SocialPanelController? controller = SocialPanelController.Bind(
+ layout, MakeCallbacks(friends: friends));
+ Assert.NotNull(controller);
+
+ UiElement friendsPage = UiElement.FindDescendant(controller!.TabPanel, 0x10000513u)!;
+ var listBox = Assert.IsType(
+ UiElement.FindDescendant(friendsPage, 0x10000517u));
+
+ friends.Apply(new FriendsUpdate(
+ FriendsUpdateType.Add,
+ new List { new(1u, "Alice", true, false, System.Array.Empty(), System.Array.Empty()) }));
+
+ // Panel never shown — Tick() must not rebuild.
+ controller!.Tick();
+ Assert.Equal(0, listBox.ViewportForTest?.Children.Count ?? 0);
+
+ // Showing the panel and ticking again picks up the accumulated bump.
+ controller.OnShown();
+ controller.Tick();
+ Assert.Single(listBox.ViewportForTest!.Children);
+ }
}