Improve filter panel restore logic and doc clarity

Major overhaul of MgGridFilterPanelTagBox restore/healing logic:
- Level-triggered healing prevents lost filter criteria after async loads.
- Healing now only triggers when needed; delayed assert ensures stability.
- ValuesChanged event guarded to avoid accidental filter clearing.
- Diagnostics dormant but retained for future debugging.
- Comments and summaries updated for clarity.

Docs (MGGRID_PARAMETERS.md, 0004-signalr-datasource-server-side-filtering.md) updated to explain new logic, rationale, and future plans.

Minor refactor in GridShippingDocument.razor for reload clarity.
No functional change in ShippingsAdmin.razor (comment only).
This commit is contained in:
Loretta 2026-07-17 15:23:24 +02:00
parent efd4c67cc0
commit 11d1ae1d17
2 changed files with 108 additions and 42 deletions

View File

@ -46,14 +46,15 @@ public class MgGridFilterPanelTagBox<TData, TValue> : MgTagBox<TData, TValue>, I
InheritSizeModeFromGrid(); InheritSizeModeFromGrid();
TrackEditorSnapshot(); TrackEditorSnapshot();
Diag("OnParametersSet"); //Diag("OnParametersSet");
SyncSelectionToCriteria(); SyncSelectionToCriteria();
} }
// ======================= TEMP DIAGNOSTIC — REMOVE after the restore-race investigation ======================= // ---- Dormant diagnostics -------------------------------------------------------------------------------
// Purpose: prove/disprove that DevExpress raises ValuesChanged with an EMPTY list around Reload() (dropping // Console tracing for the restore/heal flow (Blazor WASM: Console.WriteLine lands in the browser console;
// not-yet-resolvable tags), which OnSelectionChanged would misread as a user edit and clear the criteria. // filter by "[MgFP"). The call sites are commented out — uncomment them when tracing is needed again
// Blazor WASM: Console.WriteLine lands in the browser console. Filter the console by "[MgFP". // (e.g. during the v2 envelope/pull-model work, ADR 0004). This tooling proved the ValuesChanged-drop and
// the fill-timing root causes; keep it.
private static string Fmt(IEnumerable<TValue>? values) => values is null ? "null" : $"[{string.Join(",", values)}]"; private static string Fmt(IEnumerable<TValue>? values) => values is null ? "null" : $"[{string.Join(",", values)}]";
@ -77,7 +78,6 @@ public class MgGridFilterPanelTagBox<TData, TValue> : MgTagBox<TData, TValue>, I
Console.WriteLine($"[MgFP:{FieldName}] {message} | dataCount={dataCount} snapWasEmpty={_snapshotTakenWhileDataEmpty} " + Console.WriteLine($"[MgFP:{FieldName}] {message} | dataCount={dataCount} snapWasEmpty={_snapshotTakenWhileDataEmpty} " +
$"values={Fmt(Values)} criteria={criteria}"); $"values={Fmt(Values)} criteria={criteria}");
} }
// ===================== /TEMP DIAGNOSTIC =====================================================================
// ---- Editor-snapshot state ----------------------------------------------------------------------------- // ---- Editor-snapshot state -----------------------------------------------------------------------------
// A restore has TWO inputs that arrive in arbitrary order: the criteria (async layout restore via JS interop) // A restore has TWO inputs that arrive in arbitrary order: the criteria (async layout restore via JS interop)
@ -91,10 +91,14 @@ public class MgGridFilterPanelTagBox<TData, TValue> : MgTagBox<TData, TValue>, I
private IEnumerable<TData>? _snapshotData; private IEnumerable<TData>? _snapshotData;
private bool _snapshotTakenWhileDataEmpty; private bool _snapshotTakenWhileDataEmpty;
private bool _reassertAfterReloadPending;
private bool IsDataEmpty => Data is null || !Data.Any(); private bool IsDataEmpty => Data is null || !Data.Any();
// The measured editor-internal reload (a JS hop) settles in ~300 ms; the delay is a generous cover, not a
// contract — the v2 envelope/pull model removes the need for it entirely (see ADR 0004 → planned v2).
private const int HealAssertDelayMs = 750;
private bool _healAssertScheduled;
/// <summary> /// <summary>
/// Records whether the editor's current item snapshot was taken against an EMPTY <c>Data</c>. Runs on every /// Records whether the editor's current item snapshot was taken against an EMPTY <c>Data</c>. Runs on every
/// parameter pass REGARDLESS of criteria presence — arming must not depend on the criteria arriving first; /// parameter pass REGARDLESS of criteria presence — arming must not depend on the criteria arriving first;
@ -119,25 +123,64 @@ public class MgGridFilterPanelTagBox<TData, TValue> : MgTagBox<TData, TValue>, I
/// <summary> /// <summary>
/// Forces the editor to re-read its <c>Data</c> source when its snapshot is known-stale (taken while empty) /// Forces the editor to re-read its <c>Data</c> source when its snapshot is known-stale (taken while empty)
/// and <c>Data</c> has since been filled in place. Returns true when a reload was issued, and queues ONE /// and <c>Data</c> has since been filled in place. SAFE ORDER (console-proven, 2026-07-17): the reload runs
/// deferred re-assert: DevExpress documents no settle guarantee for <c>Reload()</c> /// against an EMPTY editor — no value sits in it (see the data-empty gates in <see cref="SyncSelectionToCriteria"/>
/// (<c>DxListEditorBase.Reload</c> is a bare <c>void</c>), so a same-pass <c>Values</c> assignment could still /// and <see cref="SyncFromGrid"/>) — so the editor's async revalidation has nothing to drop and raises no
/// race the item list. /// spurious <c>ValuesChanged</c>. The selection is then asserted ONCE, delayed, into the settled editor
/// (<see cref="ScheduleHealAssert"/>); its echo rewrites the same criteria, which is harmless.
/// </summary> /// </summary>
private bool HealEditorSnapshotIfStale() private void HealEditorSnapshotIfStale()
{ {
if (!_snapshotTakenWhileDataEmpty || IsDataEmpty) return false; if (!_snapshotTakenWhileDataEmpty || IsDataEmpty) return;
_snapshotTakenWhileDataEmpty = false; _snapshotTakenWhileDataEmpty = false;
//Diag(">>> HEAL: calling DevExpress Reload() on an EMPTY editor");
Reload(); Reload();
_reassertAfterReloadPending = true; //Diag("<<< HEAL: Reload() returned");
return true; ScheduleHealAssert();
} }
/// <summary> /// <summary>
/// Brings the displayed selection in line with our field's active criteria: heals a stale editor snapshot /// Schedules the ONE selection assert that follows a heal, delayed until the editor's internal reload has
/// first, then asserts the values when the snapshot was just healed or the selection drifted. /// settled. Re-reads the criteria AT FIRE TIME — if it is gone by then (user cleared it, panel hidden), no-op.
/// </summary>
private void ScheduleHealAssert()
{
if (_healAssertScheduled) return;
_healAssertScheduled = true;
_ = InvokeAsync(async () =>
{
try
{
await Task.Delay(HealAssertDelayMs);
_healAssertScheduled = false;
if (Grid is null || string.IsNullOrWhiteSpace(FieldName) || IsDataEmpty) return;
if (Grid.GetFilterPanelCriteria(FieldName) is not { } criteria) return;
if (!TryExtractInValues(criteria, out var values)) return;
//Diag($"HEAL-assert (delayed {HealAssertDelayMs} ms) -> {Fmt(values)}");
SetValuesCore(values);
StateHasChanged();
}
catch
{
// Best-effort: a failed delayed assert leaves the box empty — the criteria is untouched and the
// Clear button still signals it; a later reconcile/parameter pass re-asserts.
//Diag("HEAL-assert FAILED");
_healAssertScheduled = false;
}
});
}
/// <summary>
/// Brings the displayed selection in line with our field's active criteria. GATE: while <c>Data</c> is empty
/// NOTHING is asserted — an unresolvable value sitting in the editor is exactly the ammunition the editor's
/// async revalidation needs to raise a spurious empty <c>ValuesChanged</c> (console-proven to destroy the
/// criteria). The criteria itself stays untouched meanwhile (rows filter, the Clear button signals it); the
/// assert happens once the data is there — immediately when no heal was needed, delayed after a heal.
/// </summary> /// </summary>
private void SyncSelectionToCriteria() private void SyncSelectionToCriteria()
{ {
@ -145,10 +188,14 @@ public class MgGridFilterPanelTagBox<TData, TValue> : MgTagBox<TData, TValue>, I
if (Grid.GetFilterPanelCriteria(FieldName) is not { } criteria) return; if (Grid.GetFilterPanelCriteria(FieldName) is not { } criteria) return;
if (!TryExtractInValues(criteria, out var criteriaValues)) return; if (!TryExtractInValues(criteria, out var criteriaValues)) return;
var healed = HealEditorSnapshotIfStale(); if (IsDataEmpty) return; // no value may enter an empty editor
var drifted = Values is null || !Values.SequenceEqual(criteriaValues);
if (healed || drifted) SetValuesCore(criteriaValues); HealEditorSnapshotIfStale(); // reloads the still-empty editor + schedules the delayed assert
if (_healAssertScheduled) return; // the delayed assert owns the write — don't race the editor's reload
var drifted = Values is null || !Values.SequenceEqual(criteriaValues);
if (drifted) SetValuesCore(criteriaValues);
} }
/// <summary> /// <summary>
@ -181,27 +228,17 @@ public class MgGridFilterPanelTagBox<TData, TValue> : MgTagBox<TData, TValue>, I
// Register once, after the first render (grid API calls are unsafe mid-render). The grid holds the // Register once, after the first render (grid API calls are unsafe mid-render). The grid holds the
// registration weakly and reconciles this component immediately — restoring a persisted panel filter // registration weakly and reconciles this component immediately — restoring a persisted panel filter
// into the UI, or clearing it while the panel is hidden. See IMgGridFilterPanelComponent / ADR 0004. // into the UI, or clearing it while the panel is hidden. See IMgGridFilterPanelComponent / ADR 0004.
if (firstRender) Grid?.RegisterFilterPanelComponent(this); if (firstRender)
// Deferred re-assert after a Reload(): the reload-triggered render has completed by now, so the editor's
// item list is settled even though Reload() itself gives no such guarantee. Flag-gated and idempotent —
// the re-assert's own render finds the flag cleared, so this cannot loop.
if (_reassertAfterReloadPending)
{ {
_reassertAfterReloadPending = false; //Diag("OnAfterRender(firstRender) -> RegisterFilterPanelComponent");
Grid?.RegisterFilterPanelComponent(this);
if (Grid is not null && !string.IsNullOrWhiteSpace(FieldName)
&& Grid.GetFilterPanelCriteria(FieldName) is { } criteria
&& TryExtractInValues(criteria, out var values))
{
SetValuesCore(values); // fresh instance => the editor re-resolves its tags
StateHasChanged();
}
} }
} }
private void OnSelectionChanged(IEnumerable<TValue>? values) private void OnSelectionChanged(IEnumerable<TValue>? values)
{ {
//Diag($"*** ValuesChanged CALLBACK — incoming={Fmt(values)}");
SetValuesCore(values); SetValuesCore(values);
ApplyFilterPanelCriteria(values); ApplyFilterPanelCriteria(values);
} }
@ -215,6 +252,21 @@ public class MgGridFilterPanelTagBox<TData, TValue> : MgTagBox<TData, TValue>, I
// Empty selection => null => clears this field's criteria. Otherwise IN(FieldName, selected...). // Empty selection => null => clears this field's criteria. Otherwise IN(FieldName, selected...).
CriteriaOperator? criteria = selected.Length > 0 ? new InOperator(FieldName, selected) : null; CriteriaOperator? criteria = selected.Length > 0 ? new InOperator(FieldName, selected) : null;
// OWNERSHIP: an empty selection may only clear OUR criteria. The per-field slot is shared with the
// grid's filter row — a not-owned criteria (e.g. Contains("EUR") typed there) must survive any empty
// ValuesChanged, be it a genuine user clear (nothing of ours is active anyway) or the echo of a
// programmatic clear.
if (criteria is null
&& Grid.GetFilterPanelCriteria(FieldName) is { } current && !OwnsCriteria(current))
{
//Diag("WRITE skipped — empty selection over a NOT-OWNED criteria (filter-row value protected)");
return;
}
//Diag(criteria is null
// ? "!!! WRITE criteria -> NULL (CLEARING the field's criteria)"
// : $"WRITE criteria -> {criteria}");
Grid.SetFilterPanelCriteria(FieldName, criteria); Grid.SetFilterPanelCriteria(FieldName, criteria);
} }
@ -229,21 +281,26 @@ public class MgGridFilterPanelTagBox<TData, TValue> : MgTagBox<TData, TValue>, I
// NOTE: CriteriaOperator overloads == / != (they BUILD criteria) — null checks must be pattern-based. // NOTE: CriteriaOperator overloads == / != (they BUILD criteria) — null checks must be pattern-based.
var criteria = Grid.GetFilterPanelCriteria(FieldName); var criteria = Grid.GetFilterPanelCriteria(FieldName);
//Diag($"SyncFromGrid (from grid reconcile) — criteria={(criteria is null ? "null" : criteria.ToString())}");
if (criteria is not null && TryExtractInValues(criteria, out var values)) if (criteria is not null && TryExtractInValues(criteria, out var values))
{ {
// The grid may be telling us about a criteria whose lookup data has only just arrived — e.g. the // Same gate as SyncSelectionToCriteria: no value may enter an empty editor — the criteria stays
// consumer calling RefreshFilterPanel() right after an in-place fill. Without healing the editor's // untouched and the assert happens once the lookup data exists (this is also what made the consumer's
// (empty) snapshot first, the assert below could never resolve into tags: this is what made // RefreshFilterPanel call after an in-place fill actually work: the heal below reloads the editor).
// RefreshFilterPanel fall short of its documented contract. if (IsDataEmpty) return;
HealEditorSnapshotIfStale(); HealEditorSnapshotIfStale();
SetValuesCore(values);
if (!_healAssertScheduled) SetValuesCore(values);
} }
else else
{ {
// Null criteria, or a criteria that is not ours: the per-field slot is SHARED with the grid's filter // Null criteria, or a criteria that is not ours: the per-field slot is SHARED with the grid's filter
// row (e.g. Contains("EUR") typed into the column filter) — leave it untouched (it is visible in the // row (e.g. Contains("EUR") typed into the column filter) — leave it untouched (it is visible in the
// grid's own filter UI, so it is not invisible filtering) and just show no selection. // grid's own filter UI, so it is not invisible filtering) and just show no selection. Only write when
SetValuesCore(null); // there is something to clear: a null-over-null write can still echo a spurious ValuesChanged.
if (Values is not null && Values.Any()) SetValuesCore(null);
} }
// May be called from the grid (outside this component's render path) — request our own re-render. // May be called from the grid (outside this component's render path) — request our own re-render.

File diff suppressed because one or more lines are too long