Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions docs/viewer-selection-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Viewer selection sync

How the grid cells of the FLASHDeconv / FLASHTnT Viewer share a selection, and the invariants that keep the tables populated. Background: the September 2026 report "table contents only load when I select the first element" (root cause and reproduction in OpenMS/FLASHApp#100 and the linked session report).

## Moving parts

- Every grid cell is its own Streamlit component instance (one iframe of the Vue bundle). Cells share selection state only by round-tripping it through Python: `src/render/render.py::render_component` sends `selection_store` (the `StateTracker` state plus `counter` and `id`) with every render, and a cell that changes a selection calls `Streamlit.setComponentValue` with its whole store, which Python merges in `src/render/StateTracker.py`.
- One `StateTracker` exists per (tool, experiment selector). `render_grid` replaces it with a fresh one (new random `id`, empty state) whenever the selected experiment changes. The Vue store clears its local selection when it sees a new `id`.
- `render_component` hashes each cell's payload; the Vue store skips re-parsing a render whose hash equals the previous one (the heatmap payload is large).
- Tabulator tables select their first row when they are (re)built and emit that selection. This is what seeds `scanIndex` (Scan Table) or `proteinIndex` (Protein Table) after a load; the Mass Table, spectra and 3D plot are filtered by that selection on the Python side and are empty until it arrives.

## Invariants

1. **The payload hash includes the tracker id** (`src/render/util.py::payload_hash`). Two runs of the same input produce byte-identical tables; without the id the Scan Table skipped the render after an experiment switch, was never rebuilt, never seeded the default row, and every dependent cell stayed empty. The Vue store additionally refuses the hash skip when the tracker id changed, so either side alone is enough.
2. **`None` never claims a key.** The frontend sends `null` for every unset field. `StateTracker.updateState` adopts unknown keys only when their value is not `None`; otherwise the first cell to report would own every key and a later first-time value carrying the same counter (the Scan Table's default row) would be dropped as a stale conflict.
3. **A click never deselects.** Tabulator runs with `selectableRows: 'highlight'` and a `rowClick` handler that leaves exactly the clicked row selected, so clicking the highlighted row re-emits the selection instead of clearing it. Old Tabulator instances are destroyed before a table is rebuilt.
4. **Empty data clears the spectra.** The spectrum component replaces its plot with the "No Data Available" placeholder when its scan data goes away, instead of keeping the previous experiment's spectrum on screen. It no longer writes `massIndex` to the shared store on data changes; the cells that change the scan (Scan Table, Protein Table, heatmap click) reset the mass selection to the first mass.

## Known caveat

`StateTracker` ignores a conflicting update whose `counter` is older than the server's, which drops a genuine click sent while another cell's update is in flight (window of tens of milliseconds locally, longer with network latency). Such drops are logged at debug level (`StateTracker: ignored update ...`). Tracked in OpenMS/FLASHApp#100.

## Tests and reproduction

- `tests/test_selection_clear.py` pins invariants 1 and 2 (pure Python, no Streamlit needed).
- Vue: `src/stores/__tests__/streamlit-data.spec.ts` in openms-streamlit-vue-component pins the hash/tracker rule (`npx vitest run`).
- End to end: switch the Viewer dropdown between two runs of the same file; the Mass Table must fill without a click.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions js-component/dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
<link rel="icon" href="./favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>openms-streamlit-vue-component</title>
<script type="module" crossorigin src="./assets/index-ec8d47d1.js"></script>
<link rel="stylesheet" href="./assets/index-801f5ae0.css">
<script type="module" crossorigin src="./assets/index-853ebafb.js"></script>
<link rel="stylesheet" href="./assets/index-ab2e8bcd.css">
</head>
<body>
<div id="app"></div>
Expand Down
41 changes: 30 additions & 11 deletions src/render/StateTracker.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import logging

import numpy as np

logger = logging.getLogger(__name__)


class StateTracker():
def __init__(self):
# Stores the current state, increments when state is updated
Expand All @@ -11,43 +16,57 @@ def updateState(self, newState):
# Reject if updates are from different tracker
if newState['id'] != self.id:
return False

# Track if any modifications were made
modified = False

# Extract counter
counter = newState.pop('counter')

# We always take previously undefined keys
# We always take previously undefined keys. A None ("nothing selected") must
# not claim a key: the frontend sends null for every unset field, so the first
# cell to report would otherwise own every key, and a later first-time value
# carrying the same counter (e.g. the Scan Table's default row) would be
# treated as a stale conflict below and dropped.
for k, v in newState.items():
if k == 'id' or v is None:
continue
if k not in self.currentState:
self.currentState[k] = v
modified = True

conflicts = {
k: newState[k] for k in newState.keys()
if k in self.currentState and self.currentState[k] != newState[k]
}

# We only accept conflicts for new states
if counter >= self.currentStateCounter:
conflicts = {
k: newState[k] for k in newState.keys()
if self.currentState[k] != newState[k]
}

if len(conflicts) != 0:
modified = True

for k, v in conflicts.items():
self.currentState[k] = v

elif conflicts:
# A cell sent a change while it had not yet received the latest state
# (another cell's update was in flight). The change is lost; log it so
# "my click did nothing" reports can be checked against the server log.
logger.debug(
'StateTracker: ignored update with counter %s < %s for keys %s',
counter, self.currentStateCounter, sorted(conflicts)
)

if modified:
self.currentStateCounter += 1


if modified:
return True
else:
return False

def getState(self):
# Never return the original object, deepcopy shouldnt be
# Never return the original object, deepcopy shouldnt be
# neccessary as dict is not nested
state = self.currentState.copy()
state['counter'] = self.currentStateCounter
Expand Down
8 changes: 5 additions & 3 deletions src/render/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import streamlit as st
from streamlit.runtime.scriptrunner import get_script_run_ctx

from src.render.util import hash_complex
from src.render.util import payload_hash
from src.render.StateTracker import StateTracker
from src.render.initialize import initialize_data
from src.render.update import update_data, filter_data
Expand Down Expand Up @@ -42,8 +42,10 @@ def render_component(
data, out_components, active_state, additional_data, tool
)

# Hash updated. filtered data
data['hash'] = hash_complex(data)
# Hash the filtered data together with the tracker id: the frontend skips renders
# whose hash is unchanged, and a new experiment must rebuild every cell even when
# its data is byte-identical to the previous experiment's (re-run of the same file).
data['hash'] = payload_hash(data, state['id'])

# Render component
data['selection_store'] = state
Expand Down
15 changes: 14 additions & 1 deletion src/render/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,17 @@

def hash_complex(d):
serialized = pickle.dumps(d)
return hashlib.sha256(serialized).hexdigest()
return hashlib.sha256(serialized).hexdigest()


def payload_hash(data, tracker_id):
"""Hash of a grid cell's payload as sent to the Vue component.

The frontend skips re-parsing a render whose hash equals the previous one. The
hash therefore has to change whenever the cell must rebuild, and that includes a
new experiment: two runs of the same input produce byte-identical tables, so the
data alone is not enough. The StateTracker id is new for every experiment and
constant across reruns within one, so it keeps the skip for unchanged data and
forces a rebuild (and the default row selection) when the experiment changes.
"""
return hash_complex((data, tracker_id))
40 changes: 40 additions & 0 deletions tests/test_selection_clear.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from src.render.StateTracker import StateTracker
from src.render.util import payload_hash


def _echo_with(tracker, **overrides):
Expand Down Expand Up @@ -72,3 +73,42 @@ def test_dropped_key_keeps_stale_value_regression():
tracker.updateState(payload)

assert tracker.getState()["AApos"] == 5 # stale value survives -> the original bug


def test_all_none_first_message_does_not_claim_keys():
"""The frontend sends null for every unset field, so the first cell to report
(e.g. a spectrum) carries scanIndex=None, massIndex=None, ... Those must not be
adopted as state: otherwise the Scan Table's default row selection, sent with the
same counter, is treated as a stale conflict and the dependent cells stay empty
until the user clicks a row (OpenMS/FLASHApp#100)."""
tracker = StateTracker()
all_none = _echo_with(tracker, scanIndex=None, massIndex=None, AApos=None)
assert tracker.updateState(all_none) is False
assert tracker.currentStateCounter == 0
assert "scanIndex" not in tracker.getState()

default_row = _echo_with(tracker, scanIndex=0, massIndex=0) # still counter 0
assert tracker.updateState(default_row) is True
assert tracker.getState()["scanIndex"] == 0
assert tracker.getState()["massIndex"] == 0


def test_none_for_a_never_set_key_is_a_no_op():
"""Clearing an existing key keeps its semantics (see above); a None for a key that
was never set changes nothing and must not raise."""
tracker = StateTracker()
tracker.updateState(_echo_with(tracker, AApos=5))
assert tracker.updateState(_echo_with(tracker, AApos=None, tagIndex=None)) is True
echoed = tracker.getState()
assert echoed["AApos"] is None
assert "tagIndex" not in echoed


def test_payload_hash_changes_with_the_tracker():
"""Two runs of the same input give byte-identical tables. The payload hash must
still differ per experiment (= per StateTracker), or the frontend skips the
render, never rebuilds its tables and never seeds the default row selection."""
data = {"per_scan_data": [{"index": 0, "Scan": 3098}]}
first, second = StateTracker(), StateTracker()
assert payload_hash(data, first.id) != payload_hash(data, second.id)
assert payload_hash(data, first.id) == payload_hash(dict(data), first.id)
Loading