diff --git a/src/Reactor/Controls/DataGrid/DataGridComponent.cs b/src/Reactor/Controls/DataGrid/DataGridComponent.cs index 70f08ae90..cc6ce1ebb 100644 --- a/src/Reactor/Controls/DataGrid/DataGridComponent.cs +++ b/src/Reactor/Controls/DataGrid/DataGridComponent.cs @@ -179,13 +179,18 @@ public override Element Render() var rowH = el.RowHeight ?? el.EstimatedRowHeight; var pageSize = Math.Max(50, (int)Math.Ceiling(2160.0 / rowH)); - var request = new DataRequest + // Memoize the DataRequest on the sort/filter/search/pageSize identity. Rebuilding it + // every render allocated two fresh List copies (Sort/Filters) whose references then + // changed UseDataSource's deps every render, restarting pagination. Keyed on the + // version counters, the request and its list references stay stable until the + // underlying sort/filter/search actually change. + var request = UseMemo(() => new DataRequest { PageSize = pageSize, Sort = state.Sorts.Count > 0 ? state.Sorts.ToList() : null, Filters = state.Filters.Count > 0 ? state.Filters.ToList() : null, SearchQuery = state.SearchQuery, - }; + }, state.SortVersion, state.FilterVersion, state.SearchQuery ?? string.Empty, pageSize); var resource = UseDataSource( source, @@ -221,7 +226,11 @@ void OnDataChanged(object? sender, EventArgs e) // Load data on mount and when sort changes (legacy path only — hook path // reacts to sort/filter changes through its own deps). - var sortKey = string.Join(",", state.Sorts.Select(s => $"{s.Field}:{s.Direction}")); + // Memoize the sort key so the Select + interpolation + Join only runs when sorts change, + // not on every render. It feeds the LoadDataAsync effect's deps below. + var sortKey = UseMemo( + () => string.Join(",", state.Sorts.Select(s => $"{s.Field}:{s.Direction}")), + state.SortVersion); UseEffect(() => { @@ -243,6 +252,11 @@ void OnDataChanged(object? sender, EventArgs e) var itemCount = state.ItemCount; + // Stable identity for the search box's onChanged delegate. Declared unconditionally here so + // the hook order is fixed even though the search box is conditional (ShowSearch can toggle); + // built lazily where the search box is rendered below. + var onSearchRef = UseRef?>(null); + // ── Build the UI ──────────────────────────────────────────── // Use a WinUI Grid instead of FlexColumn for the DataGrid root container. // This breaks the FlexPanel ancestor chain so header column width changes @@ -254,15 +268,21 @@ void OnDataChanged(object? sender, EventArgs e) if (el.ShowSearch) { var searchQuery = state.SearchQuery ?? ""; - gridChildren.Add( - TextBox(searchQuery, q => + // Cache the onChanged delegate so it keeps a stable identity across renders. It only + // captures the stable `state`, so a once-built handler is equivalent to rebuilding it + // every render — this drops a per-render closure allocation and lets the search box reuse + // its control on the reconciler's skip path instead of re-running Update. + onSearchRef.Current ??= q => + { + Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread()?.TryEnqueue(() => { - Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread()?.TryEnqueue(() => - { - state.SetSearchQuery(q); - _ = state.LoadDataAsync(); - }); - }).Padding(horizontal: 8, vertical: 4).Grid(row: gridRow, column: 0) + state.SetSearchQuery(q); + _ = state.LoadDataAsync(); + }); + }; + gridChildren.Add( + TextBox(searchQuery, onSearchRef.Current) + .Padding(horizontal: 8, vertical: 4).Grid(row: gridRow, column: 0) ); gridRow++; } @@ -309,26 +329,33 @@ void OnDataChanged(object? sender, EventArgs e) } gridChildren.Add(dataContent.Grid(row: gridRow, column: 0)); - // Build row definitions: "Auto" for header + count, "*" for data area. - var rootRowDefs = new string[gridRow + 1]; - for (int r = 0; r < gridRow; r++) rootRowDefs[r] = "Auto"; - rootRowDefs[gridRow] = "*"; - - // DataGrid builds row-track strings dynamically via its existing - // string-based path; the typed migration is tracked as a follow-up to - // spec 033 §1 because it requires reshaping rootRowDefs all the way up. -#pragma warning disable CS0618 // Use string-form Grid overload (deprecated, scheduled for removal) - var gridEl = Grid(new[] { "*" }, rootRowDefs, gridChildren.ToArray()); -#pragma warning restore CS0618 + // Root grid: one "*" column, with `gridRow` leading "Auto" rows (search / headers / count) + // and a final "*" row for the data area. gridRow is 0..3, so the row-def arrays and their + // GridDefinitions are precomputed once (RootGridDefCache) instead of allocating a string[] + // + GridDefinition every render. The stable definition reference also lets the reconciler + // skip re-applying the root grid's row/column definitions when the row count is unchanged. + var rootDef = gridRow < RootGridDefCache.Length + ? RootGridDefCache[gridRow] + : new GridDefinition(RootColsStar, BuildRootRowDefs(gridRow)); + var gridEl = new GridElement(rootDef, FilterRowChildren(gridChildren.ToArray())); // Commit active edit when focus leaves the DataGrid entirely. // Attached once at mount via Setters; the handler reads current state from the ref. + // Hooks must run unconditionally and in the same order every render, so both refs are + // declared outside the el.Editable branch (Editable can toggle between renders, which + // would otherwise change the hook call sequence and throw HookOrderException). + var lostFocusWired = UseRef(false); + var lostFocusSetters = UseRef[]?>(null); if (el.Editable) { - var lostFocusWired = UseRef(false); - gridEl = gridEl with + // Cache the LostFocus setter array (and its closure) in a ref so the spread + lambda + // aren't re-allocated every render. The handler wires g.LostFocus exactly once (guarded + // by lostFocusWired) and reads live state through the captured refs, so a once-built + // setter is equivalent to rebuilding it each render. gridEl is freshly constructed + // above with no setters, so assigning Setters here matches the old spread. + lostFocusSetters.Current ??= new Action[] { - Setters = [.. gridEl.Setters, g => + g => { if (lostFocusWired.Current) return; lostFocusWired.Current = true; @@ -370,8 +397,9 @@ void OnDataChanged(object? sender, EventArgs e) } }); }; - }] + } }; + gridEl = gridEl with { Setters = lostFocusSetters.Current }; } Element grid = gridEl; @@ -423,36 +451,22 @@ private static Element RenderDataRows( var totalItems = state.ItemCount; var selectable = el.SelectionMode != SelectionMode.None; var editable = el.Editable; - var colCount = columns.Count; - var colWidths = new double[colCount]; - for (int c = 0; c < colCount; c++) - colWidths[c] = state.GetColumnWidth(columns[c].Name); - - // Build Grid column definitions: one pixel column per data column, - // plus an optional expand column for row details, - // plus an optional 40px selection checkbox column at the start, - // plus an optional actions column for Row edit mode. + // Cache the per-column pixel widths + the shared GridDefinition in state. Rebuilt only when + // a column changes (see DataGridState.GetColumnLayout) — this eliminates the per-render + // double[] + string[] + per-column double.ToString. The header row and every data row reuse + // the same GridDefinition reference, so the reconciler skips re-applying its + // ColumnDefinitions each render. var hasRowDetailTemplate = el.RowDetailTemplate is not null; var hasRowEditActions = editable && el.EditMode == EditMode.Row; - var gridColCount = colCount - + (hasRowDetailTemplate ? 1 : 0) - + (selectable ? 1 : 0) - + (hasRowEditActions ? 1 : 0); - var gridColDefs = new string[gridColCount]; - var idx = 0; - if (hasRowDetailTemplate) gridColDefs[idx++] = "24"; - if (selectable) gridColDefs[idx++] = "40"; - for (int c = 0; c < colCount; c++) - gridColDefs[idx++] = colWidths[c].ToString(global::System.Globalization.CultureInfo.InvariantCulture); - if (hasRowEditActions) gridColDefs[idx] = "Auto"; - var rowDef = new string[] { "*" }; + var (colWidths, gridDef) = state.GetColumnLayout( + columns, hasRowDetailTemplate, selectable, hasRowEditActions); return VirtualList( itemCount: totalItems, renderItem: index => { - return RenderRow(index, state, columns, el, registry, colWidths, gridColDefs, rowDef); + return RenderRow(index, state, columns, el, registry, colWidths, gridDef); }, itemHeight: el.RowHeight, estimatedItemHeight: el.EstimatedRowHeight, @@ -496,8 +510,7 @@ private static Element RenderRow( DataGridElement el, TypeRegistry registry, double[] colWidths, - string[] gridColDefs, - string[] rowDef) + GridDefinition gridDef) { var item = state.GetItemAt(index); var keyStr = state.GetRowKeyAt(index); @@ -690,10 +703,10 @@ private static Element RenderRow( : (index % 2 == 0 ? LayerFill : CardBackground); // Use a WinUI Grid with pixel column definitions instead of FlexRow. // Grid with pixel columns avoids Yoga layout entirely — the dominant - // cost identified by profiling. -#pragma warning disable CS0618 // dynamic gridColDefs string[] — typed migration tracked - Element row = Grid(gridColDefs, rowDef, cells); -#pragma warning restore CS0618 + // cost identified by profiling. Construct it directly with the cached, shared + // GridDefinition (FilterRowChildren mirrors the Grid(...) factory's child handling) so the + // reconciler reuses the definition reference and skips rebuilding ColumnDefinitions. + Element row = new GridElement(gridDef, FilterRowChildren(cells)); row = row.Background(rowBg); // Click handler — always present (maintains element tree structure). @@ -889,16 +902,10 @@ private static Element RenderHeaderRow( var editable = el.Editable; var hasRowEditActions = editable && el.EditMode == EditMode.Row; - // Build column definition strings for the header Grid. - var gridColDefs = new string[colCount + cellOffset + (hasRowEditActions ? 1 : 0)]; - var idx = 0; - if (hasRowDetailTemplate) gridColDefs[idx++] = "24"; - if (selectable) gridColDefs[idx++] = "40"; - for (int c = 0; c < colCount; c++) - gridColDefs[idx++] = state.GetColumnWidth(columns[c].Name) - .ToString(global::System.Globalization.CultureInfo.InvariantCulture); - if (hasRowEditActions) gridColDefs[idx] = "Auto"; - var rowDef = new string[] { "*" }; + // Reuse the cached column layout (shared with the data rows) so the header doesn't + // rebuild the column-definition strings or re-run double.ToString each render. + var (_, gridDef) = state.GetColumnLayout( + columns, hasRowDetailTemplate, selectable, hasRowEditActions); var headerCells = new List(); @@ -970,9 +977,7 @@ private static Element RenderHeaderRow( .Grid(row: 0, column: colCount + cellOffset)); } -#pragma warning disable CS0618 // dynamic gridColDefs string[] — typed migration tracked - return Grid(gridColDefs, rowDef, headerCells.ToArray()); -#pragma warning restore CS0618 + return new GridElement(gridDef, FilterRowChildren(headerCells.ToArray())); } // Cached GridDefinition for the resize grip overlay. Using a static instance @@ -980,6 +985,75 @@ private static Element RenderHeaderRow( // fast update path (property changes only) instead of remounting the Grid. private static readonly GridDefinition ResizeOverlayDef = new(["*"], ["*"]); + // Root grid layout caches (#129). The root grid always has a single "*" column and `gridRow` + // leading "Auto" rows (optional search box / header row / total-count row) followed by a final + // "*" data row. gridRow is 0..3, so all four row-definition arrays and their GridDefinitions + // are built once and reused — avoiding a per-render string[] + GridDefinition allocation. The + // stable GridDefinition reference also lets the reconciler skip re-applying the root grid's + // row/column definitions when the optional-row count is unchanged. + private static readonly string[] RootColsStar = ["*"]; + + private static readonly string[][] RootRowDefsCache = + [ + BuildRootRowDefs(0), + BuildRootRowDefs(1), + BuildRootRowDefs(2), + BuildRootRowDefs(3), + ]; + + private static readonly GridDefinition[] RootGridDefCache = + [ + new GridDefinition(RootColsStar, RootRowDefsCache[0]), + new GridDefinition(RootColsStar, RootRowDefsCache[1]), + new GridDefinition(RootColsStar, RootRowDefsCache[2]), + new GridDefinition(RootColsStar, RootRowDefsCache[3]), + ]; + + // Builds the root grid's row definitions: `gridRow` leading "Auto" rows + a final "*" data row. + private static string[] BuildRootRowDefs(int gridRow) + { + var rows = new string[gridRow + 1]; + for (int i = 0; i < gridRow; i++) rows[i] = "Auto"; + rows[gridRow] = "*"; + return rows; + } + + // Mirrors Dsl.FilterChildren (which is private): flattens GroupElements and removes null / + // EmptyElement children, with a fast path that aliases the input array when no expansion is + // needed. Replicated here so the DataGrid can build GridElements directly with a cached + // GridDefinition while preserving the exact child semantics of the Grid(...) factory. + private static Element[] FilterRowChildren(Element?[] children) + { + bool needsExpansion = false; + for (int i = 0; i < children.Length; i++) + { + if (children[i] is null or GroupElement or EmptyElement) + { + needsExpansion = true; + break; + } + } + if (!needsExpansion) return (Element[])(object)children; + + var result = new List(); + for (int i = 0; i < children.Length; i++) + { + if (children[i] is GroupElement group) + { + foreach (var gc in group.Children) + { + if (gc is not null and not EmptyElement) + result.Add(gc); + } + } + else if (children[i] is not null and not EmptyElement) + { + result.Add(children[i]!); + } + } + return result.ToArray(); + } + private static readonly Microsoft.UI.Xaml.Media.SolidColorBrush TransparentBrush = new(Microsoft.UI.Colors.Transparent); private static readonly Microsoft.UI.Xaml.Media.SolidColorBrush ResizeHoverBrush = diff --git a/src/Reactor/Controls/DataGrid/DataGridState.cs b/src/Reactor/Controls/DataGrid/DataGridState.cs index 69a1cf0ee..f980cd150 100644 --- a/src/Reactor/Controls/DataGrid/DataGridState.cs +++ b/src/Reactor/Controls/DataGrid/DataGridState.cs @@ -34,6 +34,22 @@ public class DataGridState /// Current filter descriptors. public IReadOnlyList Filters => _filters; + // Version counters + O(1) lookup maps kept in sync with _sorts / _filters. They let the + // component memoize sort/filter-derived render output (the sort key, the DataRequest) and + // let header rendering look up per-column sort/filter state without re-running LINQ each + // render. _sorts is mutated only in ToggleSort; _filters only in Set/Clear/ClearAll — every + // one of those bumps the matching version and refreshes the matching map. + private int _sortVersion; + private int _filterVersion; + private readonly Dictionary _sortDirByField = new(); + private readonly Dictionary _filterByField = new(); + + /// Monotonically increasing version, bumped whenever sort state changes. + internal int SortVersion => _sortVersion; + + /// Monotonically increasing version, bumped whenever filter state changes. + internal int FilterVersion => _filterVersion; + // ── Selection state ────────────────────────────────────────── private readonly HashSet _selectedKeys = new(); @@ -125,9 +141,65 @@ public class DataGridState private readonly Dictionary _columnWidths = new(); private readonly HashSet _hiddenColumns = new(); + // Bumped on every column add/remove/reorder/resize/pin/hide/show. Drives the visible-column + // cache and the per-render column-layout cache (GetColumnLayout), and lets the component + // treat column-derived render output as stable while it is unchanged. + private int _columnVersion; + + // O(1) name -> index map over the full _columns list. First-wins, mirroring the prior + // FirstOrDefault/FindIndex(c => c.Name == name) semantics. Rebuilt only when column + // membership/order changes (ctor + ReorderColumn); pin replaces a descriptor in place so the + // index is unaffected. + private readonly Dictionary _columnIndexByName = new(); + + // Cached visible-column projection (excludes hidden). Rebuilt lazily when _columnVersion + // advances past the version it was last built at — avoids a Where + ToList on every access. + private List? _visibleColumns; + private int _visibleColumnsBuiltVersion = -1; + + // Per-render column-layout cache: pixel widths + a shared GridDefinition reused by the header + // row and every data row. Keyed on _columnVersion + the layout "shape" bitfield. The stable + // GridDefinition reference lets the reconciler skip re-applying ColumnDefinitions each render. + private static readonly string[] RowDefStar = { "*" }; + private double[]? _cachedColWidths; + private GridDefinition? _cachedGridDef; + private int _layoutCacheVersion = -1; + private int _layoutCacheShape = -1; + // The column list the cache was built against. _columnVersion only tracks internal mutations + // (resize/hide/show/reorder/pin); the caller-supplied columns (el.Columns / auto-generated) + // can change independently, so reference identity is part of the cache key to avoid returning + // a layout sized for a stale column set. + private IReadOnlyList? _layoutCacheColumns; + private int _layoutCacheColumnCount = -1; + + /// Monotonically increasing version, bumped whenever column order/size/visibility changes. + internal int ColumnVersion => _columnVersion; + /// Current column definitions (in display order), excluding hidden columns. - public IReadOnlyList Columns => - _hiddenColumns.Count == 0 ? _columns : _columns.Where(c => !_hiddenColumns.Contains(c.Name)).ToList(); + public IReadOnlyList Columns + { + get + { + // Cache the visible-column snapshot per column-version so repeated reads within a render + // don't re-run the Where+ToList. On invalidation a *fresh* list is built (never an + // in-place Clear/refill, and never the internal _columns list itself) so any reference + // previously handed out keeps the contents it was observed with — preserving the + // snapshot semantics of the old Where(...).ToList() across later column mutations + // (ReorderColumn/PinColumn mutate _columns in place). + if (_visibleColumns is null || _visibleColumnsBuiltVersion != _columnVersion) + { + var snapshot = new List(_columns.Count); + for (int i = 0; i < _columns.Count; i++) + { + if (_hiddenColumns.Count == 0 || !_hiddenColumns.Contains(_columns[i].Name)) + snapshot.Add(_columns[i]); + } + _visibleColumns = snapshot; + _visibleColumnsBuiltVersion = _columnVersion; + } + return _visibleColumns; + } + } /// All column definitions, including hidden ones. public IReadOnlyList AllColumns => _columns; @@ -418,6 +490,7 @@ public DataGridState(IDataSource source, IReadOnlyList colum _columns = new List(columns); _selectionMode = selectionMode; _blockSize = blockSize; + RebuildColumnIndex(); } // ── Sort operations ────────────────────────────────────────── @@ -463,12 +536,23 @@ public void ToggleSort(string field, bool additive = false) } } + BumpSortVersion(); StateChanged?.Invoke(); } + // Rebuilds the field -> direction map from _sorts. ToggleSort keeps at most one descriptor + // per field, so last-write-wins here matches the prior FirstOrDefault(field).Direction. + private void BumpSortVersion() + { + _sortVersion++; + _sortDirByField.Clear(); + for (int i = 0; i < _sorts.Count; i++) + _sortDirByField[_sorts[i].Field] = _sorts[i].Direction; + } + /// Gets the current sort direction for a column, or null if unsorted. public SortDirection? GetSortDirection(string field) - => _sorts.FirstOrDefault(s => s.Field == field)?.Direction; + => _sortDirByField.TryGetValue(field, out var dir) ? dir : null; // ── Filter operations ─────────────────────────────────────── @@ -477,6 +561,7 @@ public void SetFilter(FilterDescriptor filter) { _filters.RemoveAll(f => f.Field == filter.Field); _filters.Add(filter); + BumpFilterVersion(); StateChanged?.Invoke(); } @@ -484,7 +569,10 @@ public void SetFilter(FilterDescriptor filter) public void ClearFilter(string field) { if (_filters.RemoveAll(f => f.Field == field) > 0) + { + BumpFilterVersion(); StateChanged?.Invoke(); + } } /// Remove all filters. @@ -493,13 +581,24 @@ public void ClearAllFilters() if (_filters.Count > 0) { _filters.Clear(); + BumpFilterVersion(); StateChanged?.Invoke(); } } + // Rebuilds the field -> filter map from _filters. SetFilter keeps at most one filter per + // field, so last-write-wins here matches the prior FirstOrDefault(field). + private void BumpFilterVersion() + { + _filterVersion++; + _filterByField.Clear(); + for (int i = 0; i < _filters.Count; i++) + _filterByField[_filters[i].Field] = _filters[i]; + } + /// Gets the active filter for a column, or null. public FilterDescriptor? GetFilter(string field) - => _filters.FirstOrDefault(f => f.Field == field); + => _filterByField.TryGetValue(field, out var filter) ? filter : null; // ── Search state ──────────────────────────────────────────── @@ -533,14 +632,17 @@ public void HandleRowClick(RowKey key, bool ctrlKey = false, bool shiftKey = fal return; } - // Multiple selection mode — use internal key cache for range selection if no explicit order - var order = visibleOrder; - if (order is null && _rowKeyCache.Length > 0) - order = _rowKeyCache.Select(k => new RowKey(k)).ToList(); - - if (shiftKey && AnchorKey is not null && order is not null) + // Multiple selection mode. For range (shift) selection we need an ordering: prefer the + // explicit visibleOrder, otherwise fall back to the internal row-key cache. The cache + // fallback scans by index (SelectRangeByKeyCache) instead of materializing the entire + // _rowKeyCache into a List — that materialization boxed/allocated one RowKey per + // row (100k+ on large client-fallback loads) on every click, not just shift-clicks. + if (shiftKey && AnchorKey is not null && (visibleOrder is not null || _rowKeyCache.Length > 0)) { - SelectRange(AnchorKey.Value, key, order); + if (visibleOrder is not null) + SelectRange(AnchorKey.Value, key, visibleOrder); + else + SelectRangeByKeyCache(AnchorKey.Value, key); } else if (ctrlKey) { @@ -584,6 +686,37 @@ public void SelectRange(RowKey from, RowKey to, IReadOnlyList visibleOrd StateChanged?.Invoke(); } + // Range-select against the internal _rowKeyCache (string[]) without materializing a + // List. Behaviourally identical to SelectRange(from, to, _rowKeyCache.Select(k => + // new RowKey(k)).ToList()): RowKey is a record struct whose equality is its ordinal Value + // string, so comparing cache strings is the same as comparing the constructed RowKeys, and + // last-occurrence-wins matches SelectRange's loop. No-ops (no version bump) when either key + // is absent, exactly as SelectRange does. + private void SelectRangeByKeyCache(RowKey from, RowKey to) + { + var fromValue = from.Value; + var toValue = to.Value; + var fromIndex = -1; + var toIndex = -1; + for (int i = 0; i < _rowKeyCache.Length; i++) + { + if (_rowKeyCache[i] == fromValue) fromIndex = i; + if (_rowKeyCache[i] == toValue) toIndex = i; + } + + if (fromIndex < 0 || toIndex < 0) return; + + var start = Math.Min(fromIndex, toIndex); + var end = Math.Max(fromIndex, toIndex); + + _selectedKeys.Clear(); + for (int i = start; i <= end; i++) + _selectedKeys.Add(new RowKey(_rowKeyCache[i])); + + _selectionVersion++; + StateChanged?.Invoke(); + } + /// Select all provided keys. public void SelectAll(IReadOnlyList allKeys) { @@ -615,20 +748,78 @@ public double GetColumnWidth(string columnName) if (_columnWidths.TryGetValue(columnName, out var width)) return width; - var col = _columns.FirstOrDefault(c => c.Name == columnName); + var col = _columnIndexByName.TryGetValue(columnName, out var i) ? _columns[i] : null; return col?.Width ?? 120; } /// Resize a column and trigger a re-render. public void ResizeColumn(string columnName, double newWidth) { - var col = _columns.FirstOrDefault(c => c.Name == columnName); + var col = _columnIndexByName.TryGetValue(columnName, out var i) ? _columns[i] : null; var minWidth = col?.MinWidth ?? 40; var maxWidth = col?.MaxWidth ?? double.MaxValue; _columnWidths[columnName] = Math.Clamp(newWidth, minWidth, maxWidth); + _columnVersion++; StateChanged?.Invoke(); } + /// + /// Returns the cached per-column pixel widths and the shared for + /// the current visible columns + layout shape. Stable across renders (same array and + /// definition references) until a column is added/removed/reordered/resized/pinned/hidden/ + /// shown — tracked by . The header row and every data row share the + /// one returned definition so the reconciler can skip re-applying ColumnDefinitions. + /// + /// Current visible columns (i.e. ). + /// Whether a 24px row-detail expander column leads the grid. + /// Whether a 40px selection column leads the grid. + /// Whether an Auto-width row-edit actions column trails the grid. + internal (double[] ColWidths, GridDefinition GridDef) GetColumnLayout( + IReadOnlyList columns, + bool hasRowDetailColumn, + bool hasSelectColumn, + bool hasRowEditActionsColumn) + { + int shape = (hasRowDetailColumn ? 1 : 0) + | (hasSelectColumn ? 2 : 0) + | (hasRowEditActionsColumn ? 4 : 0); + + if (_cachedColWidths is not null && _cachedGridDef is not null + && _layoutCacheVersion == _columnVersion && _layoutCacheShape == shape + && ReferenceEquals(_layoutCacheColumns, columns) + && _layoutCacheColumnCount == columns.Count) + { + return (_cachedColWidths, _cachedGridDef); + } + + int colCount = columns.Count; + var colWidths = new double[colCount]; + for (int c = 0; c < colCount; c++) + colWidths[c] = GetColumnWidth(columns[c].Name); + + int gridColCount = colCount + + (hasRowDetailColumn ? 1 : 0) + + (hasSelectColumn ? 1 : 0) + + (hasRowEditActionsColumn ? 1 : 0); + var gridColDefs = new string[gridColCount]; + int idx = 0; + if (hasRowDetailColumn) gridColDefs[idx++] = "24"; + if (hasSelectColumn) gridColDefs[idx++] = "40"; + for (int c = 0; c < colCount; c++) + gridColDefs[idx++] = colWidths[c].ToString(global::System.Globalization.CultureInfo.InvariantCulture); + if (hasRowEditActionsColumn) gridColDefs[idx] = "Auto"; + + var gridDef = new GridDefinition(gridColDefs, RowDefStar); + + _cachedColWidths = colWidths; + _cachedGridDef = gridDef; + _layoutCacheVersion = _columnVersion; + _layoutCacheShape = shape; + _layoutCacheColumns = columns; + _layoutCacheColumnCount = columns.Count; + return (colWidths, gridDef); + } + /// Reorder a column to a new position. public void ReorderColumn(int fromIndex, int toIndex) @@ -640,21 +831,41 @@ public void ReorderColumn(int fromIndex, int toIndex) var col = _columns[fromIndex]; _columns.RemoveAt(fromIndex); _columns.Insert(toIndex, col); + RebuildColumnIndex(); + _columnVersion++; StateChanged?.Invoke(); } + // Rebuilds the name -> index map over _columns. First-wins so it mirrors the prior + // FirstOrDefault/FindIndex(c => c.Name == name) behaviour for any duplicate names. + private void RebuildColumnIndex() + { + _columnIndexByName.Clear(); + for (int i = 0; i < _columns.Count; i++) + { + if (!_columnIndexByName.ContainsKey(_columns[i].Name)) + _columnIndexByName[_columns[i].Name] = i; + } + } + /// Hide a column. public void HideColumn(string columnName) { if (_hiddenColumns.Add(columnName)) + { + _columnVersion++; StateChanged?.Invoke(); + } } /// Show a previously hidden column. public void ShowColumn(string columnName) { if (_hiddenColumns.Remove(columnName)) + { + _columnVersion++; StateChanged?.Invoke(); + } } /// Toggle column visibility. @@ -662,6 +873,7 @@ public void ToggleColumnVisibility(string columnName) { if (!_hiddenColumns.Remove(columnName)) _hiddenColumns.Add(columnName); + _columnVersion++; StateChanged?.Invoke(); } @@ -692,9 +904,11 @@ public void ToggleColumnVisibility(string columnName) /// Pin a column to a position at runtime. public void PinColumn(string columnName, PinPosition position) { - var idx = _columns.FindIndex(c => c.Name == columnName); - if (idx < 0) return; + if (!_columnIndexByName.TryGetValue(columnName, out var idx)) return; _columns[idx] = _columns[idx] with { Pin = position }; + // Pin replaces the descriptor in place — name/index unchanged, so _columnIndexByName stays + // valid — but bump the version so the visible-column and layout caches pick up the new Pin. + _columnVersion++; StateChanged?.Invoke(); } @@ -981,7 +1195,7 @@ public void UpdateEditingValue(object? value) var rowIndex = GetRowIndex(rowKey); if (rowIndex < 0) { CancelEdit(); return null; } - var col = _columns.FirstOrDefault(c => c.Name == colName); + var col = _columnIndexByName.TryGetValue(colName, out var colIdx) ? _columns[colIdx] : null; if (col?.SetValue is null) { CancelEdit(); return null; } var item = GetItemAt(rowIndex); @@ -1118,7 +1332,7 @@ public void UpdateRowEditValue(string columnName, object? value) // Apply all pending values via return-new-owner SetValue foreach (var (colName, newValue) in _rowEditValues) { - var col = _columns.FirstOrDefault(c => c.Name == colName); + var col = _columnIndexByName.TryGetValue(colName, out var colIdx) ? _columns[colIdx] : null; if (col?.SetValue is null) continue; current = (T)col.SetValue(current, newValue); } @@ -1242,7 +1456,7 @@ private void ValidateField(string fieldName, object? value) { if (_editValidation is null) return; - var col = _columns.FirstOrDefault(c => c.Name == fieldName); + var col = _columnIndexByName.TryGetValue(fieldName, out var fieldIdx) ? _columns[fieldIdx] : null; if (col is null) return; _editValidation.ClearInternal(fieldName); @@ -1266,7 +1480,7 @@ public async Task ValidateFieldAsync(string fieldName, object? value, Cancellati { if (_editValidation is null) return; - var col = _columns.FirstOrDefault(c => c.Name == fieldName); + var col = _columnIndexByName.TryGetValue(fieldName, out var fieldIdx) ? _columns[fieldIdx] : null; if (col?.AsyncValidators is not { Count: > 0 }) return; foreach (var validator in col.AsyncValidators) diff --git a/tests/Reactor.Tests/DataGridPerfCacheTests.cs b/tests/Reactor.Tests/DataGridPerfCacheTests.cs new file mode 100644 index 000000000..afa9b4bda --- /dev/null +++ b/tests/Reactor.Tests/DataGridPerfCacheTests.cs @@ -0,0 +1,513 @@ +using System.Globalization; +using System.Linq; +using Microsoft.UI.Reactor.Controls; +using Microsoft.UI.Reactor.Core; +using Microsoft.UI.Reactor.Data; +using Xunit; + +namespace Microsoft.UI.Reactor.Tests; + +/// +/// Tests for the per-render allocation / LINQ caching added to DataGridState +/// (version counters, O(1) sort/filter/width lookups, the shared column-layout +/// cache, the visible-columns cache, and the index-based range selection). +/// Each test asserts the cached/memoized path produces results identical to the +/// previous LINQ-based behavior and that caches invalidate on the right mutations. +/// +public class DataGridPerfCacheTests +{ + // ── Test helpers (mirror DataGridStateAdditionalTests) ─────────── + + private record TestItem(int Id, string Name, double Score); + + private sealed class TestDataSource : IDataSource + { + private readonly List _items; + public TestDataSource(params TestItem[] items) => _items = new(items); + + public Task> GetPageAsync(DataRequest request, CancellationToken ct = default) + => Task.FromResult(new DataPage(_items, TotalCount: _items.Count)); + + public RowKey GetRowKey(TestItem item) => new(item.Id.ToString(CultureInfo.InvariantCulture)); + public DataSourceCapabilities Capabilities => DataSourceCapabilities.None; + } + + private static readonly FieldDescriptor[] TestColumns = + [ + new FieldDescriptor + { + Name = "Id", + FieldType = typeof(int), + GetValue = obj => ((TestItem)obj).Id, + IsReadOnly = true, + }, + new FieldDescriptor + { + Name = "Name", + FieldType = typeof(string), + GetValue = obj => ((TestItem)obj).Name, + SetValue = (obj, val) => ((TestItem)obj) with { Name = (string)(val ?? "") }, + }, + new FieldDescriptor + { + Name = "Score", + FieldType = typeof(double), + GetValue = obj => ((TestItem)obj).Score, + SetValue = (obj, val) => ((TestItem)obj) with { Score = (double)(val ?? 0.0) }, + Width = 80, + MinWidth = 50, + MaxWidth = 200, + }, + ]; + + private static DataGridState CreateState(SelectionMode mode = SelectionMode.Multiple) + => new(new TestDataSource( + new TestItem(1, "Alice", 95), + new TestItem(2, "Bob", 87), + new TestItem(3, "Carol", 92) + ), TestColumns, mode); + + // Loads via the client-side fallback path (source advertises no server sort/filter, so an + // active sort forces "load all + sort locally"), which is the path that populates the internal + // row-key cache scanned by the index-based range selection. + private static async Task> CreateClientFallbackLoadedState( + SelectionMode mode = SelectionMode.Multiple) + { + var state = CreateState(mode); + state.ToggleSort("Id"); // ascending -> rows ordered 1,2,3 + await state.LoadDataAsync(TestContext.Current.CancellationToken); + return state; + } + + // ════════════════════════════════════════════════════════════════ + // Version counters (cache keys) bump exactly on the right mutations + // ════════════════════════════════════════════════════════════════ + + [Fact] + public void SortVersion_Bumps_On_Sort_Mutations_And_Is_Stable_On_Reads() + { + var state = CreateState(); + var v0 = state.SortVersion; + + // Pure reads must not bump the version (otherwise the memoized sort key + // would be rebuilt every render). + _ = state.GetSortDirection("Name"); + _ = state.Sorts; + Assert.Equal(v0, state.SortVersion); + + state.ToggleSort("Name"); // None -> Asc + var v1 = state.SortVersion; + Assert.True(v1 > v0); + + state.ToggleSort("Name"); // Asc -> Desc + var v2 = state.SortVersion; + Assert.True(v2 > v1); + + state.ToggleSort("Name"); // Desc -> removed + Assert.True(state.SortVersion > v2); + } + + [Fact] + public void FilterVersion_Bumps_On_Filter_Mutations_Only() + { + var state = CreateState(); + var v0 = state.FilterVersion; + + state.SetFilter(new FilterDescriptor("Name", FilterOperator.Contains, "Al")); + var v1 = state.FilterVersion; + Assert.True(v1 > v0); + + // No-op clear of a non-existent filter must NOT bump. + state.ClearFilter("DoesNotExist"); + Assert.Equal(v1, state.FilterVersion); + + state.ClearFilter("Name"); + var v2 = state.FilterVersion; + Assert.True(v2 > v1); + + // No-op clear-all on an already-empty filter set must NOT bump. + state.ClearAllFilters(); + Assert.Equal(v2, state.FilterVersion); + + state.SetFilter(new FilterDescriptor("Score", FilterOperator.GreaterThan, 90)); + state.ClearAllFilters(); + Assert.True(state.FilterVersion > v2); + } + + [Fact] + public void ColumnVersion_Bumps_On_Resize_Hide_Show_Reorder_Pin() + { + var state = CreateState(); + + var v = state.ColumnVersion; + state.ResizeColumn("Score", 100); + Assert.True(state.ColumnVersion > v); + + v = state.ColumnVersion; + state.HideColumn("Name"); + Assert.True(state.ColumnVersion > v); + + v = state.ColumnVersion; + state.ShowColumn("Name"); + Assert.True(state.ColumnVersion > v); + + v = state.ColumnVersion; + state.ReorderColumn(0, 2); + Assert.True(state.ColumnVersion > v); + + v = state.ColumnVersion; + state.PinColumn("Id", PinPosition.Left); + Assert.True(state.ColumnVersion > v); + } + + // ════════════════════════════════════════════════════════════════ + // O(1) lookups produce the same answers as the previous LINQ scans + // ════════════════════════════════════════════════════════════════ + + [Fact] + public void GetSortDirection_Matches_Linq_Over_Sorts() + { + var state = CreateState(); + state.ToggleSort("Name"); + state.ToggleSort("Score", additive: true); + state.ToggleSort("Score", additive: true); // Score: Asc -> Desc + + foreach (var col in new[] { "Id", "Name", "Score" }) + { + var expected = state.Sorts + .Where(s => s.Field == col) + .Select(s => (SortDirection?)s.Direction) + .FirstOrDefault(); + Assert.Equal(expected, state.GetSortDirection(col)); + } + } + + [Fact] + public void GetFilter_Matches_Linq_Over_Filters() + { + var state = CreateState(); + state.SetFilter(new FilterDescriptor("Name", FilterOperator.Contains, "Al")); + state.SetFilter(new FilterDescriptor("Score", FilterOperator.GreaterThan, 90)); + + foreach (var col in new[] { "Id", "Name", "Score" }) + { + var expected = state.Filters.FirstOrDefault(f => f.Field == col); + Assert.Equal(expected, state.GetFilter(col)); + } + } + + [Fact] + public void GetColumnWidth_Matches_Manual_Lookup_After_Resize() + { + var state = CreateState(); + state.ResizeColumn("Score", 150); + state.ResizeColumn("Id", 70); + + // Independent reference mirroring the previous LINQ semantics: an explicit + // resize override wins, else the descriptor's declared Width, else 120. + var overrides = new Dictionary { ["Score"] = 150, ["Id"] = 70 }; + foreach (var col in state.AllColumns) + { + double expected = overrides.TryGetValue(col.Name, out var w) + ? w + : (col.Width ?? 120); + Assert.Equal(expected, state.GetColumnWidth(col.Name)); + } + + Assert.Equal(150, state.GetColumnWidth("Score")); + Assert.Equal(70, state.GetColumnWidth("Id")); + Assert.Equal(120, state.GetColumnWidth("Name")); // unset -> default + } + + // ════════════════════════════════════════════════════════════════ + // Column-layout cache (#125): shared, stable references; correct content + // ════════════════════════════════════════════════════════════════ + + [Fact] + public void GetColumnLayout_Returns_Stable_References_Until_Column_Change() + { + var state = CreateState(); + var cols = state.Columns; + + var (w1, d1) = state.GetColumnLayout(cols, false, false, false); + var (w2, d2) = state.GetColumnLayout(cols, false, false, false); + + // Same version + same shape -> identical cached instances (no per-render alloc). + Assert.Same(w1, w2); + Assert.Same(d1, d2); + + // A column mutation must invalidate the cache and yield fresh instances. + state.ResizeColumn("Score", 100); + var (w3, d3) = state.GetColumnLayout(state.Columns, false, false, false); + Assert.NotSame(d1, d3); + Assert.NotSame(w1, w3); + + // ...and the new layout reflects the resize. + Assert.Equal(100, w3[2]); + Assert.Equal("100", d3.Columns[2]); + } + + [Fact] + public void GetColumnLayout_Content_Matches_Manual_Construction_With_Shape() + { + var state = CreateState(); + var cols = state.Columns; + + // No leading/trailing columns. + var (widths, def) = state.GetColumnLayout(cols, false, false, false); + Assert.Equal(cols.Count, def.Columns.Length); + Assert.Equal(new[] { "*" }, def.Rows); + for (int c = 0; c < cols.Count; c++) + { + Assert.Equal(state.GetColumnWidth(cols[c].Name), widths[c]); + Assert.Equal( + state.GetColumnWidth(cols[c].Name).ToString(CultureInfo.InvariantCulture), + def.Columns[c]); + } + + // Full shape: row-detail (24) + select (40) leading, edit-actions (Auto) trailing. + var (_, shaped) = state.GetColumnLayout(cols, true, true, true); + Assert.Equal(cols.Count + 3, shaped.Columns.Length); + Assert.Equal("24", shaped.Columns[0]); + Assert.Equal("40", shaped.Columns[1]); + for (int c = 0; c < cols.Count; c++) + Assert.Equal( + state.GetColumnWidth(cols[c].Name).ToString(CultureInfo.InvariantCulture), + shaped.Columns[2 + c]); + Assert.Equal("Auto", shaped.Columns[^1]); + } + + // ════════════════════════════════════════════════════════════════ + // Visible-columns cache (#127) + // ════════════════════════════════════════════════════════════════ + + [Fact] + public void Columns_Cache_Reflects_Hide_Show_And_Is_Reused_Within_A_Version() + { + var state = CreateState(); + + Assert.Equal(3, state.Columns.Count); + Assert.Same(state.Columns, state.Columns); // repeated access -> no Where+ToList each time + + state.HideColumn("Score"); + var hidden = state.Columns; + Assert.Equal(new[] { "Id", "Name" }, hidden.Select(c => c.Name).ToArray()); + Assert.DoesNotContain(hidden, c => c.Name == "Score"); + Assert.Same(hidden, state.Columns); // cached across repeated access + + state.ShowColumn("Score"); + Assert.Equal(new[] { "Id", "Name", "Score" }, state.Columns.Select(c => c.Name).ToArray()); + } + + // ════════════════════════════════════════════════════════════════ + // Index-based range selection (#130) + // ════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ShiftClick_Without_VisibleOrder_Matches_Explicit_VisibleOrder() + { + // Path A: shift-click with no visibleOrder -> scans the internal row-key cache + // (SelectRangeByKeyCache) instead of materializing it into a List. + var a = await CreateClientFallbackLoadedState(); + var order = Enumerable.Range(0, a.ItemCount) + .Select(i => new RowKey(a.GetRowKeyAt(i)!)) + .ToList(); + + a.HandleRowClick(order[0]); + a.HandleRowClick(order[^1], shiftKey: true); // no visibleOrder + + // Path B: identical clicks but with an explicit visibleOrder equal to the cache order. + var b = await CreateClientFallbackLoadedState(); + b.HandleRowClick(order[0]); + b.HandleRowClick(order[^1], shiftKey: true, visibleOrder: order); + + Assert.Equal( + b.SelectedKeys.Select(k => k.Value).OrderBy(v => v), + a.SelectedKeys.Select(k => k.Value).OrderBy(v => v)); + + // And the full range really was selected (proves the cache was scanned, not ignored). + Assert.Equal(a.ItemCount, a.SelectedKeys.Count); + } + + [Fact] + public async Task ShiftClick_KeyCache_Scan_NoOp_When_Anchor_Absent_From_Cache() + { + // Mirrors SelectRange's no-op-when-key-missing behavior on the index-scan path. + var state = await CreateClientFallbackLoadedState(); + state.HandleRowClick(new RowKey("1")); // anchor = "1" + + var before = state.SelectedKeys.Select(k => k.Value).OrderBy(v => v).ToArray(); + state.HandleRowClick(new RowKey("999"), shiftKey: true); // "999" not in cache + var after = state.SelectedKeys.Select(k => k.Value).OrderBy(v => v).ToArray(); + + // "to" key absent -> range select no-ops; selection is unchanged. + Assert.Equal(before, after); + } + + [Fact] + public async Task ShiftClick_Reversed_KeyCache_Scan_Selects_Same_Range_As_Forward() + { + // Anchor on the LAST row, shift-click the FIRST -> the index scan runs "backwards" + // (start = Min, end = Max), so it must still select the whole inclusive range. + var a = await CreateClientFallbackLoadedState(); + var order = Enumerable.Range(0, a.ItemCount) + .Select(i => new RowKey(a.GetRowKeyAt(i)!)) + .ToList(); + + a.HandleRowClick(order[^1]); + a.HandleRowClick(order[0], shiftKey: true); // no visibleOrder -> reversed key-cache scan + Assert.Equal(a.ItemCount, a.SelectedKeys.Count); + + // The same reversed clicks with an explicit visibleOrder select the identical set. + var b = await CreateClientFallbackLoadedState(); + b.HandleRowClick(order[^1]); + b.HandleRowClick(order[0], shiftKey: true, visibleOrder: order); + + Assert.Equal( + b.SelectedKeys.Select(k => k.Value).OrderBy(v => v), + a.SelectedKeys.Select(k => k.Value).OrderBy(v => v)); + } + + [Fact] + public void SelectRange_Reversed_Missing_And_Empty_Behave_Like_Old_Loop() + { + var state = CreateState(); + var order = new List { new("1"), new("2"), new("3"), new("4") }; + + // Reversed (from after to) selects the same inclusive range as forward (Min/Max). + state.SelectRange(new RowKey("4"), new RowKey("2"), order); + Assert.Equal(new[] { "2", "3", "4" }, + state.SelectedKeys.Select(k => k.Value).OrderBy(v => v).ToArray()); + + // Missing "from" anchor -> no-op (early return before clearing), selection preserved. + state.SelectRange(new RowKey("nope"), new RowKey("2"), order); + Assert.Equal(new[] { "2", "3", "4" }, + state.SelectedKeys.Select(k => k.Value).OrderBy(v => v).ToArray()); + + // Missing "to" target -> no-op. + state.SelectRange(new RowKey("1"), new RowKey("nope"), order); + Assert.Equal(new[] { "2", "3", "4" }, + state.SelectedKeys.Select(k => k.Value).OrderBy(v => v).ToArray()); + + // Empty visibleOrder -> no-op. + state.SelectRange(new RowKey("1"), new RowKey("4"), new List()); + Assert.Equal(new[] { "2", "3", "4" }, + state.SelectedKeys.Select(k => k.Value).OrderBy(v => v).ToArray()); + } + + // ════════════════════════════════════════════════════════════════ + // Column-layout cache (#125): keyed on the caller-supplied column list too + // ════════════════════════════════════════════════════════════════ + + [Fact] + public void GetColumnLayout_Invalidates_When_Columns_Reference_Changes() + { + var state = CreateState(); + var colsA = state.Columns; // 3 columns + var (wA, dA) = state.GetColumnLayout(colsA, false, false, false); + Assert.Equal(3, wA.Length); + + // A different column-list reference (e.g. the app swapped el.Columns) with a different + // count must NOT be served colsA's cached layout, even though no internal column mutation + // bumped ColumnVersion. Returning the stale 3-wide arrays here would mis-size the grid (and + // index out of range when the row renderer walks the new, shorter column set). + var colsB = new List { state.AllColumns[0], state.AllColumns[1] }; + var (wB, dB) = state.GetColumnLayout(colsB, false, false, false); + + Assert.NotSame(wA, wB); + Assert.NotSame(dA, dB); + Assert.Equal(2, wB.Length); + Assert.Equal(2, dB.Columns.Length); + + // Re-passing the original reference rebuilds for it again (cache now holds colsB). + var (wA2, _) = state.GetColumnLayout(colsA, false, false, false); + Assert.Equal(3, wA2.Length); + } + + [Fact] + public void GetColumnLayout_Rebuilds_When_Same_Reference_Is_Mutated_In_Place() + { + var state = CreateState(); + var live = new List(state.AllColumns); // 3 columns + var (w1, _) = state.GetColumnLayout(live, false, false, false); + Assert.Equal(3, w1.Length); + + // Same reference, but its element count changed (pathological in-place edit). The count + // guard forces a rebuild so we never return an array sized for the old column count. + live.RemoveAt(2); + var (w2, _) = state.GetColumnLayout(live, false, false, false); + + Assert.NotSame(w1, w2); + Assert.Equal(2, w2.Length); + } + + [Fact] + public void GetColumnLayout_Invalidates_On_Reorder_Pin_And_ToggleVisibility() + { + var state = CreateState(); + var (_, d0) = state.GetColumnLayout(state.Columns, false, false, false); + + state.ReorderColumn(0, 2); + var (_, dReorder) = state.GetColumnLayout(state.Columns, false, false, false); + Assert.NotSame(d0, dReorder); + + state.PinColumn("Id", PinPosition.Left); + var (_, dPin) = state.GetColumnLayout(state.Columns, false, false, false); + Assert.NotSame(dReorder, dPin); + + state.ToggleColumnVisibility("Name"); // hide Name + var (_, dToggle) = state.GetColumnLayout(state.Columns, false, false, false); + Assert.NotSame(dPin, dToggle); + Assert.Equal(state.Columns.Count, dToggle.Columns.Length); + } + + // ════════════════════════════════════════════════════════════════ + // Visible-columns snapshot (#127): a returned list is never mutated later + // ════════════════════════════════════════════════════════════════ + + [Fact] + public void Columns_Returns_A_Stable_Snapshot_Not_Mutated_By_Later_Column_Changes() + { + var state = CreateState(); + + var before = state.Columns; // [Id, Name, Score] + Assert.Equal(new[] { "Id", "Name", "Score" }, before.Select(c => c.Name).ToArray()); + + // Mutations that bump ColumnVersion must not retroactively rewrite a previously returned + // list (the old Where(...).ToList() handed out an independent snapshot each call). This + // guards against the cache being a live shared buffer that Hide/Reorder edits in place. + state.HideColumn("Name"); + state.ReorderColumn(0, 1); + + Assert.Equal(new[] { "Id", "Name", "Score" }, before.Select(c => c.Name).ToArray()); + + // A fresh read reflects the new state and is a distinct instance. + var after = state.Columns; + Assert.NotSame(before, after); + Assert.DoesNotContain(after, c => c.Name == "Name"); // hidden + } + + // ════════════════════════════════════════════════════════════════ + // O(1) lookups: unknown-column parity with the old LINQ FirstOrDefault + // ════════════════════════════════════════════════════════════════ + + [Fact] + public void Lookups_Match_Linq_FirstOrDefault_For_Unknown_Columns() + { + var state = CreateState(); + state.ToggleSort("Name"); + state.SetFilter(new FilterDescriptor("Score", FilterOperator.GreaterThan, 90)); + + // GetSortDirection / GetFilter on an unknown column return null, matching FirstOrDefault. + Assert.Equal( + state.Sorts.Where(s => s.Field == "Nope").Select(s => (SortDirection?)s.Direction).FirstOrDefault(), + state.GetSortDirection("Nope")); + Assert.Null(state.GetSortDirection("Nope")); + + Assert.Equal(state.Filters.FirstOrDefault(f => f.Field == "Nope"), state.GetFilter("Nope")); + Assert.Null(state.GetFilter("Nope")); + + // GetColumnWidth on an unknown column falls back to the 120 default (no resize, no descriptor). + Assert.Equal(120, state.GetColumnWidth("Nope")); + } +}