diff --git a/docs/adr/0004-signalr-datasource-server-side-filtering.md b/docs/adr/0004-signalr-datasource-server-side-filtering.md index 2c2beaa..b5bfe17 100644 --- a/docs/adr/0004-signalr-datasource-server-side-filtering.md +++ b/docs/adr/0004-signalr-datasource-server-side-filtering.md @@ -65,6 +65,8 @@ Adopt a **criteria-string** server-side filter for the SignalR DataSource. The D **Restore race — root cause + LEVEL-TRIGGERED heal (2026-07-17, multi-agent audit):** a restore has **two** inputs arriving in **arbitrary order** — the criteria (async layout restore via JS interop) and the lookup `Data` (async load). Consumers routinely bind a **pre-created empty** collection that is later filled **in place** (`AcObservableCollection.Replace`), and DevExpress **snapshots the editor's item list, detecting `Data` changes BY REFERENCE ONLY** — so an in-place fill leaves the editor holding an empty list and *no* asserted `Values` can resolve into tags (re-rendering does not help; `DxListEditorBase.Reload()` is the only re-read, and it carries no documented settle guarantee). Two successive **edge-triggered** recovery gates (reference-change, then reference+count-diff) each shipped and each failed the same way: they require an *arming* pass that records the pre-fill state, so the **last-filled** lookup — whose fill lands after its last parameter pass — is systematically stranded (rows filter, `Clear` enabled, chips empty). The audit refuted the `TextFieldName`-type, windowed-data, nullable-column, column-interference and shared-collection hypotheses; the surviving cause is purely this edge-vs-level mismatch. **Fix:** the component now tracks the *standing fact* "this editor's snapshot was taken while `Data` was empty" — armed on **every** parameter pass regardless of criteria presence, cleared only by a heal — and heals (`Reload()` + fresh-instance `Values`) from **both** `OnParametersSet` **and** `SyncFromGrid` (so `IMgGridBase.RefreshFilterPanel()` finally honours its documented contract), plus one deferred post-render re-assert covering `Reload()`'s undocumented settle timing. + **Second defect — hijacked `ValuesChanged` destroying the criteria (2026-07-17, console-proven):** the component owns `ValuesChanged`, but the callback cannot distinguish a genuine user edit from (a) the **echo** of the component's own programmatic `Values` write and (b) an **editor-internal revalidation drop** — console diagnostics caught DevExpress raising `ValuesChanged([])` unprompted right after a heal (the transient mid-`Reload`/in-place-`Reset` state rejects the not-yet-resolvable tag), which the handler misread as "user cleared the box" → wrote the criteria to `null` → the layout **AutoSave persisted the destruction** (the user's saved filter was permanently lost on every failing visit; the only upside: no invisible filter). The lookup content itself was proven resolvable (the same value is user-selectable from the same 20-item windowed list), killing the data-window explanation for the *current* repro. **Fix (2nd attempt, superseded):** a render-tick-windowed callback guard shipped and was **refuted the same day** by console evidence — the drop arrives through a JS-interop hop whose latency is unbounded in render ticks, so it landed *after* the window closed and destroyed the criteria again. **Fix (final, "safe order") — remove the drop's precondition instead of filtering callbacks:** (1) **no value is ever asserted into an empty editor** — while the lookup `Data` is empty the criteria stays untouched (rows filter, the Clear button signals it) and the editor holds no value the revalidation could drop; (2) the heal `Reload()`s the **empty** editor — nothing to drop ⇒ no spurious `ValuesChanged` is raised at all; (3) **one delayed assert** (~750 ms, generously covering the measured ~300 ms JS hop; re-reads the criteria at fire time) puts the selection into the settled editor — its echo rewrites the same criteria, which is harmless; (4) **ownership-guarded clearing**: an empty selection may only clear criteria the component OWNS — a filter-row value (`Contains("EUR")`) can never be wiped by an empty callback, genuine or echoed. Every ambiguous path still resolves toward **clearing** (never an invisible filter). Residual: the delay is measured-generous, not contractual — the v2 envelope/pull model removes the heal (and the delay) entirely. + **Planned v2 (supersedes the heal):** fold the pull model into the already-planned layout envelope (`MgGridLayoutSave { Version, GridLayoutJson, FilterPanelLayoutJson }`): strip panel-owned criteria from the DX layout at **save** time (when registered components *can* answer `OwnsCriteria`) and store them as plain `field → values`; on restore **park** them and apply criteria **and** selection atomically only once the owning component is ready (registered **and** `Data` non-empty). This makes "can this criteria be displayed yet?" true **by construction** instead of detected: no `Reload()` dependency, no rows-filtered-while-chips-empty window, `ACBLAZOR-GRID-I-D4T7` fixed by construction, and the pending-reconcile flag, the snapshot heal and `RefreshFilterPanel()` all become deletable (net complexity **down**, consumer boilerplate → zero). Standalone pull (without the envelope) was **rejected**: at auto-load time no component is registered yet, forcing shape-sniffing of the DX criteria string, which collides with the shared filter-row slot. An `INotifyCollectionChanged` subscription was also **rejected**: strong collection→component references on page-shared collections outliving `OnDemand` tab components, in a design that holds components weakly precisely because the DX seams expose no disposal hook — a lifetime bug traded for a timing bug, fixing none of the structural issues. Design decision to record when v2 lands: a parked entry with no owner this session stays parked and is **re-persisted** on save, so a temporarily absent panel cannot silently destroy a saved filter. - Verify: `DevExpress.Data` version matching the client DevExpress Blazor major; license coverage on the server package feed; linq2db translation of `CriteriaToExpressionConverter` output (function whitelist). - **Quick-search (`SearchText`) — confirmed SEPARATE from `FilterCriteria`.** Per DevExpress docs (Filter API): *"Search settings do not affect the applied filter criteria"* — `SearchText` filters in parallel, NOT folded into `FilterCriteria`. So server-side search does **not** ride the criteria-string path for free; it needs its **own** handling: a thin `SearchText → OR-Contains criteria` adapter over searchable columns (then it rides the same channel), or a separate search field. Same whitelist / DoS caveats apply (search = OR-Contains across many columns).