Skip to content

Fix reactivity#278

Draft
MackLiao wants to merge 18 commits intoBrentLab:devfrom
MackLiao:fix_reactivity
Draft

Fix reactivity#278
MackLiao wants to merge 18 commits intoBrentLab:devfrom
MackLiao:fix_reactivity

Conversation

@MackLiao
Copy link
Copy Markdown
Collaborator

86818d3 refactor: extract _SETTLED_WINDOW_SEC and update stale sidebar docstrings
c9048ee fix: pin _regulator_choices_trigger with suspend_when_hidden=False
1820565 fix: convert regulator-choices effect to lazy render to stop eager correlation queries
82c7654 fix: stabilize regulator_selector DOM via update_selectize in binding/perturbation
79fe931 fix: debounce dataset_filters read in _matrix_data to fix toggle-off race
20c74d5 perf: bump _settled_datasets debounce 0.3s → 1.0s
9e2da44 fix: eliminate spurious reactivity bursts in binding/perturbation sidebars
64d6165 feat: TFBPSHINY_PROFILE_LOG_FILE env var and startup destination log
67e9d46 fix: eliminate spurious _topn_data reactivity bursts in comparison sidebar

cmatKhan and others added 18 commits April 17, 2026 15:41
…debar

The comparison module's sidebar_controls @render.ui re-rendered all input
widgets (top_n, effect_threshold, pvalue_threshold, facet_by) whenever the
active dataset selection changed. Recreating the input widgets caused Shiny
to emit spurious change events, invalidating the threshold reactive.calc
wrappers and re-triggering _topn_data() 2-5x per user action. Per the
performance report, ~78% of all _topn_data queries were intra-burst
duplicates (264 fired vs ~50 needed).

Move the four input widgets out of the dynamic @render.ui and into the
static comparison_sidebar_ui module UI, seeded with DEFAULT_* constants.
The dynamic block is replaced with a small empty_state_message render that
emits only the empty-state banner. Inputs now have stable DOM identity
across dataset toggles, so no spurious change events fire.

Also adds DEFAULT_FACET_BY="binding" to queries.py so all four sidebar
defaults live in one place, and routes the empty-state banner through
components.empty_state(compact=True) per the CLAUDE.md convention.

Verified against tmp/time_log.txt: comparison/_topn_data re-run factor
drops from 2.4x avg / 4.2x worst to 1.0x exactly, and total DB time on
the operation falls from 73.68s to 6.34s (~12x speedup).
The profile logger's log file was hardcoded to tfbpshiny_profile.log in the
project root, which forced an extra cp step before every analysis run that
expected tmp/time_log.txt. Add a TFBPSHINY_PROFILE_LOG_FILE env var that
overrides the path so captures can land directly where the analyzer reads.

Also emit one INFO line at startup naming both logger destinations
("Logger destinations — shiny: handler=... file=... | profiler: ..."), so
env-var configuration is verifiable from the first lines of stdout instead
of having to grep for output files. Defaults are unchanged: profile log
still goes to tfbpshiny_profile.log when no env var is set.
…ebars

Apply the same static-input restructure to the binding and perturbation
modules that was used for the comparison sidebar in 67e9d46. Both modules
had identical bug patterns: a sidebar_controls @render.ui that recreated
input_radio_buttons("col_preference") and input_radio_buttons("corr_type")
whenever active_*_datasets() changed. The recreated widgets emitted
spurious change events, invalidating the corr_type / col_preference
reactive.calc wrappers and re-triggering downstream calcs unnecessarily.

Per the latest profile capture, binding/scatter ran at 6x re-run factor
and perturbation/scatter at 15x re-run factor — same root cause as
comparison/_topn_data, but the bursts are smaller in absolute time so
they were not the headline of the original report.

The fix:
- Move col_preference and corr_type radio buttons from the dynamic
  @render.ui in server/sidebar.py into the static module UI in ui.py,
  seeded with new DEFAULT_CORR_TYPE / DEFAULT_COL_PREFERENCE constants
  in each module's queries.py (mirroring the comparison/queries.py
  pattern).
- Replace sidebar_controls with a small empty_state_message that emits
  only the empty-state banner when no datasets are active, routed
  through components.empty_state(compact=True).

Verified: 64 unit tests still pass, all four module imports clean.
Expect re-run factor on binding/scatter and perturbation/scatter to drop
to 1.0x in the next profile capture, plus likely downstream improvement
on binding/_all_corr_data and perturbation/_all_corr_data which depend
on the same reactive wrappers.
Profile capture showed select_datasets/_matrix_data recomputing up to 5x
per user action (10x re-run factor on the worst burst — 280 queries fired
where ~28 would have sufficed). Root cause: the prior 0.3s debounce on
_settled_datasets only coalesced keyboard-mash speed clicks. Normal mouse
toggling has a click cadence of ~400-700ms, so each click fell outside
the debounce window and fired a full N-dataset query matrix recompute.

Bumping to 1.0s catches the typical multi-toggle session at the cost of
~0.7s extra latency before the matrix updates after the user stops
clicking. Expected impact: collapses the 3-10x re-run bursts down to ~1x
and saves ~12s of wasted DB time per session given the previous capture.

Note: there is still a natural 2.0x floor inside _matrix_data because
each pair fires two profile_span events (one per restricted-sample
query). Eliminating that requires per-dataset / per-pair @reactive.calc
memoization (PDF report rec BrentLab#2) — left as a separate larger change.
…race

Toggle-OFF fires two reactive writes in succession from
dataset_row._on_toggle:
  1. toggle_state.set(...)         - debounced 1s by _settled_datasets
  2. dataset_filters.set(...)      - clears the dataset's filters,
                                     immediately invalidates _matrix_data

Because _matrix_data was reading dataset_filters() directly (no debounce),
the second write triggered a recompute with the pre-toggle dataset list
(still containing the just-toggled-off dataset, since _settled_datasets
hadn't yet expired) but the post-toggle filters. 1s later, when the
debounce expired, the matrix recomputed AGAIN with the correct list. UX
manifestation: the matrix flickering back and forth before settling, plus
asyncio CancelledError tracebacks from Shiny canceling the first
in-flight recompute when the second invalidation arrived.

Toggle-ON does not exhibit the race because it does not fire the second
filter write.

Fix: introduce a parallel _settled_filters debounced calc and have
_matrix_data read from it. Both invalidation sources now share the same
1s settle window, so toggle-off filter-clear and dataset-list updates
coalesce into a single matrix recompute. Other dataset_filters()
consumers (modal handlers, filter button styling) keep instant response.
…/perturbation

The previous fix moved the binding/perturbation SIDEBAR inputs to a static
declaration, but the regulator dropdown lives in the WORKSPACE and was
still being recreated by a @render.ui every time _all_corr_data
invalidated. Each recreation fired a spurious input.selected_regulator
change event, cascading into 6× and 15× re-runs of the downstream
scatter/regulator_plot queries (per the new analyzer capture).

Same root cause as the comparison sidebar fix and the prior binding/
perturbation sidebar fix: a dynamic @render.ui returning a freshly
constructed input widget. Same fix template, applied at the workspace
layer this time:

- Declare ui.input_selectize("selected_regulator", ...) statically in
  binding/ui.py and perturbation/ui.py.
- Replace @render.ui regulator_selector with @reactive.effect
  _update_regulator_choices that calls ui.update_selectize(...) to push
  new choices and preserve the user's selection across data refreshes.

The pattern follows select_datasets/server/sidebar.py's existing use of
ui.update_selectize. The widget DOM persists across invalidations, so
spurious change events disappear and downstream calcs only re-run on
genuine user actions or genuine data changes.

Expected impact in the next profile capture:
- binding/scatter:           6× → 1× re-run factor
- binding/regulator_plot:    3× → 1× re-run factor
- perturbation/scatter:     15× → 1× re-run factor
- perturbation/regulator_plot: 15× → 1× re-run factor

UX side effect: when no datasets are selected the regulator dropdown is
visible-but-empty rather than absent. The empty-state messaging in the
sidebar already explains the user needs to select datasets, so this
should not be confusing.
…rrelation queries

The previous regulator_selector fix used a @reactive.effect to push
choices via ui.update_selectize. Effects run eagerly regardless of which
page is mounted, so every dataset toggle on the Selection page caused
both binding and perturbation modules to evaluate _all_corr_data() —
firing the expensive correlation queries even when the user wasn't on
those pages. That swamped the Selection page's debounce work and made
toggle-off feel laggy again.

Move the choices-push into a hidden @render.ui placeholder that is only
mounted when the binding/perturbation workspace is active. The render
machinery is lazy: when the placeholder isn't in the DOM, _all_corr_data
isn't read, so no correlation queries fire.

The placeholder returns an invisible span purely as a side-effect
carrier; the real work (ui.update_selectize) happens inside the render
function. The static input_selectize widget in workspace_ui still owns
the DOM identity, so spurious change events stay eliminated when the
user IS on the binding/perturbation page.

Net effect:
- Selection-page dataset toggles: no longer trigger binding/perturbation
  correlation queries (regression fix).
- Binding/perturbation page interactions: still benefit from the static
  widget DOM (no spurious change events on _all_corr_data invalidation).
Shiny's default output suspension policy pauses @render.ui evaluation
when the output is detected as hidden. Today the workspace_region in
app.py wholesale-replaces its child output, so the trigger is either
fully unmounted (correctly inactive) or fully mounted (correctly firing).
But if a future layout change wraps the workspace in navset_tab,
conditional_panel, or any display:none toggle, Shiny may classify the
trigger as "hidden" and silently stop firing — breaking the regulator
choices update with no error.

Decorating the render with @output(suspend_when_hidden=False) opts out
of that default and pins the side-effect to fire whenever its reactive
deps invalidate AND the output is registered, regardless of visibility
heuristics. The added comment also warns future contributors not to
"clean up" by converting back to @reactive.effect, which would
re-introduce the eager-execution regression that 1820565 fixed.

Defensive change suggested by the multi-perspective review of the
preceding three commits. No behavior change today; safety net for
future layout work.
…ings

Two cleanups from the tech-debt audit of the reactivity-fix branch:

1. Extract _SETTLED_WINDOW_SEC = 1.0 module constant in
   select_datasets/server/workspace.py and reference it from both
   _settled_datasets and _settled_filters debounce decorators. The two
   windows MUST stay equal for the toggle-off coalesce property to hold;
   making the coupling syntactically visible prevents accidental drift if
   either is retuned later.

2. Replace the stale "Render ... sidebar controls" summary in all three
   correlation-style module sidebar servers (comparison, binding,
   perturbation) with an accurate description. After the static-input
   refactor, these servers no longer render the controls — the static UI
   does. They only expose reactive accessors and render the empty-state
   banner.

No behavior change.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants