openms-insight 0.1.6__py3-none-any.whl → 0.1.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,7 +16,7 @@ from .core.registry import get_component_class, register_component
16
16
  from .core.state import StateManager
17
17
  from .rendering.bridge import clear_component_annotations, get_component_annotations
18
18
 
19
- __version__ = "0.1.6"
19
+ __version__ = "0.1.7"
20
20
 
21
21
  __all__ = [
22
22
  # Core
@@ -1,12 +1,20 @@
1
1
  """Table component using Tabulator.js."""
2
2
 
3
+ import logging
3
4
  from typing import Any, Dict, List, Optional
4
5
 
5
6
  import polars as pl
6
7
 
7
8
  from ..core.base import BaseComponent
8
9
  from ..core.registry import register_component
9
- from ..preprocessing.filtering import filter_and_collect_cached
10
+ from ..preprocessing.filtering import compute_dataframe_hash
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ # Session state key for tracking last rendered selection per table component
15
+ _LAST_SELECTION_KEY = "_svc_table_last_selection"
16
+ # Session state key for tracking last sort/filter state per table component
17
+ _LAST_SORT_FILTER_KEY = "_svc_table_last_sort_filter"
10
18
 
11
19
 
12
20
  @register_component("table")
@@ -64,6 +72,7 @@ class Table(BaseComponent):
64
72
  initial_sort: Optional[List[Dict[str, Any]]] = None,
65
73
  pagination: bool = True,
66
74
  page_size: int = 100,
75
+ pagination_identifier: Optional[str] = None,
67
76
  **kwargs,
68
77
  ):
69
78
  """
@@ -104,9 +113,13 @@ class Table(BaseComponent):
104
113
  default_row: Default row to select on load (-1 for none)
105
114
  initial_sort: List of sort configurations like [{'column': 'field', 'dir': 'asc'}]
106
115
  pagination: Enable pagination for large tables (default: True).
107
- Pagination dramatically improves performance for tables with
108
- thousands of rows by only rendering one page at a time.
116
+ When enabled, uses server-side pagination where only the current
117
+ page of data is sent to the frontend, dramatically reducing browser
118
+ memory usage for large datasets.
109
119
  page_size: Number of rows per page when pagination is enabled (default: 100)
120
+ pagination_identifier: State key for storing pagination state (page, sort,
121
+ filters). Default: "{cache_id}_page". Used by StateManager to track
122
+ pagination state across reruns.
110
123
  **kwargs: Additional configuration options
111
124
  """
112
125
  self._column_definitions = column_definitions
@@ -118,6 +131,8 @@ class Table(BaseComponent):
118
131
  self._initial_sort = initial_sort
119
132
  self._pagination = pagination
120
133
  self._page_size = page_size
134
+ # Default pagination identifier based on cache_id
135
+ self._pagination_identifier = pagination_identifier or f"{cache_id}_page"
121
136
 
122
137
  super().__init__(
123
138
  cache_id=cache_id,
@@ -138,6 +153,7 @@ class Table(BaseComponent):
138
153
  initial_sort=initial_sort,
139
154
  pagination=pagination,
140
155
  page_size=page_size,
156
+ pagination_identifier=self._pagination_identifier,
141
157
  **kwargs,
142
158
  )
143
159
 
@@ -158,6 +174,7 @@ class Table(BaseComponent):
158
174
  "initial_sort": self._initial_sort,
159
175
  "pagination": self._pagination,
160
176
  "page_size": self._page_size,
177
+ "pagination_identifier": self._pagination_identifier,
161
178
  }
162
179
 
163
180
  def _restore_cache_config(self, config: Dict[str, Any]) -> None:
@@ -171,22 +188,100 @@ class Table(BaseComponent):
171
188
  self._initial_sort = config.get("initial_sort")
172
189
  self._pagination = config.get("pagination", True)
173
190
  self._page_size = config.get("page_size", 100)
191
+ self._pagination_identifier = config.get(
192
+ "pagination_identifier", f"{self._cache_id}_page"
193
+ )
174
194
 
175
- def _get_row_group_size(self) -> int:
195
+ def get_state_dependencies(self) -> List[str]:
176
196
  """
177
- Get optimal row group size for parquet writing.
197
+ Return list of state keys that affect this component's data.
178
198
 
179
- Filtered tables use smaller row groups (10K) for better predicate
180
- pushdown granularity - this allows Polars to skip row groups that
181
- don't contain the filter value. Master tables (no filters) use
182
- larger groups (50K) since we read all data anyway.
199
+ Tables depend on:
200
+ - filters (for data filtering)
201
+ - pagination state (for page, sort, and column filters)
202
+ - interactivity identifiers (for page navigation to selected row)
183
203
 
184
204
  Returns:
185
- Number of rows per row group
205
+ List of state identifier keys
186
206
  """
187
- if self._filters:
188
- return 10_000 # Smaller groups for better filter performance
189
- return 50_000 # Larger groups for master tables
207
+ deps = list(self._filters.keys()) if self._filters else []
208
+ deps.append(self._pagination_identifier)
209
+ # Include interactivity identifiers for page navigation
210
+ if self._interactivity:
211
+ deps.extend(self._interactivity.keys())
212
+ return deps
213
+
214
+ def get_initial_selection(self, state: Dict[str, Any]) -> Optional[Dict[str, Any]]:
215
+ """
216
+ Compute the initial selection for this table WITHOUT triggering Vue updates.
217
+
218
+ Only returns a value on INITIAL LOAD when:
219
+ - Table has interactivity configured
220
+ - default_row >= 0 (not disabled)
221
+ - No selection already exists for any interactivity identifier
222
+ - Not awaiting a required filter value
223
+ - No pagination state exists yet (truly initial load, not page navigation)
224
+
225
+ This is safe because:
226
+ - We compute from the same data _prepare_vue_data() returns
227
+ - Initial load always shows page 1 with default sort
228
+ - No user-applied column filters exist yet
229
+
230
+ Args:
231
+ state: Current selection state from StateManager
232
+
233
+ Returns:
234
+ Dict mapping identifier names to their initial values,
235
+ or None if no initial selection should be set.
236
+ """
237
+ # Skip if no interactivity or default_row disabled
238
+ if not self._interactivity or self._default_row < 0:
239
+ return None
240
+
241
+ # Skip if selection already exists for ANY interactivity identifier
242
+ for identifier in self._interactivity.keys():
243
+ if state.get(identifier) is not None:
244
+ return None
245
+
246
+ # Skip if NOT initial load (pagination state exists = user already interacted)
247
+ # This ensures we only pre-compute for the true first render
248
+ pagination_state = state.get(self._pagination_identifier)
249
+ if pagination_state is not None:
250
+ return None
251
+
252
+ # Skip if awaiting required filter (no data to select from)
253
+ for identifier in self._filters.keys():
254
+ filter_value = state.get(identifier)
255
+ has_default = self._filter_defaults and identifier in self._filter_defaults
256
+ if filter_value is None and not has_default:
257
+ return None
258
+
259
+ # Get first page data and extract default row values
260
+ try:
261
+ vue_data = self._prepare_vue_data(state)
262
+ table_data = vue_data.get("tableData")
263
+ if table_data is None or len(table_data) == 0:
264
+ return None
265
+
266
+ # Clamp to available rows
267
+ default_idx = min(self._default_row, len(table_data) - 1)
268
+ if default_idx < 0:
269
+ return None
270
+
271
+ result = {}
272
+ for identifier, column in self._interactivity.items():
273
+ if column in table_data.columns:
274
+ value = table_data[column].iloc[default_idx]
275
+ # Convert numpy types to Python types for JSON serialization
276
+ if hasattr(value, "item"):
277
+ value = value.item()
278
+ result[identifier] = value
279
+
280
+ return result if result else None
281
+
282
+ except Exception:
283
+ # If anything fails, let Vue handle it normally
284
+ return None
190
285
 
191
286
  def _preprocess(self) -> None:
192
287
  """
@@ -194,6 +289,7 @@ class Table(BaseComponent):
194
289
 
195
290
  Sorts by filter columns for efficient predicate pushdown, then
196
291
  collects the LazyFrame and generates column definitions if needed.
292
+ Also computes column metadata for server-side filtering in filter dialogs.
197
293
  Data is cached by base class for fast subsequent loads.
198
294
  """
199
295
  data = self._raw_data
@@ -245,6 +341,84 @@ class Table(BaseComponent):
245
341
  # Store column definitions in preprocessed data for serialization
246
342
  self._preprocessed_data["column_definitions"] = self._column_definitions
247
343
 
344
+ # Compute column metadata for server-side filter dialogs
345
+ # This is computed once at preprocessing time and cached
346
+ column_metadata: Dict[str, Dict[str, Any]] = {}
347
+ for name, dtype in zip(schema.names(), schema.dtypes()):
348
+ meta: Dict[str, Any] = {}
349
+
350
+ if dtype in (
351
+ pl.Int8,
352
+ pl.Int16,
353
+ pl.Int32,
354
+ pl.Int64,
355
+ pl.UInt8,
356
+ pl.UInt16,
357
+ pl.UInt32,
358
+ pl.UInt64,
359
+ pl.Float32,
360
+ pl.Float64,
361
+ ):
362
+ # Numeric column - compute min/max and unique count
363
+ stats = data.select(
364
+ [
365
+ pl.col(name).min().alias("min"),
366
+ pl.col(name).max().alias("max"),
367
+ pl.col(name).n_unique().alias("n_unique"),
368
+ ]
369
+ ).collect()
370
+ min_val = stats["min"][0]
371
+ max_val = stats["max"][0]
372
+ n_unique = stats["n_unique"][0]
373
+
374
+ # If few unique values, treat as categorical
375
+ if n_unique is not None and n_unique <= 10:
376
+ meta["type"] = "categorical"
377
+ unique_vals = (
378
+ data.select(pl.col(name))
379
+ .unique()
380
+ .sort(name)
381
+ .collect()
382
+ .to_series()
383
+ .to_list()
384
+ )
385
+ meta["unique_values"] = [v for v in unique_vals if v is not None][
386
+ :100
387
+ ]
388
+ else:
389
+ meta["type"] = "numeric"
390
+ if min_val is not None:
391
+ meta["min"] = float(min_val)
392
+ if max_val is not None:
393
+ meta["max"] = float(max_val)
394
+ elif dtype == pl.Utf8:
395
+ # String column - check unique count
396
+ n_unique = data.select(pl.col(name).n_unique()).collect().item()
397
+ if n_unique is not None and n_unique <= 50:
398
+ meta["type"] = "categorical"
399
+ unique_vals = (
400
+ data.select(pl.col(name))
401
+ .unique()
402
+ .sort(name, nulls_last=True)
403
+ .collect()
404
+ .to_series()
405
+ .to_list()
406
+ )
407
+ meta["unique_values"] = [
408
+ v for v in unique_vals if v is not None and v != ""
409
+ ][:100]
410
+ else:
411
+ meta["type"] = "text"
412
+ elif dtype == pl.Boolean:
413
+ meta["type"] = "categorical"
414
+ meta["unique_values"] = [True, False]
415
+ else:
416
+ meta["type"] = "text"
417
+
418
+ column_metadata[name] = meta
419
+
420
+ self._preprocessed_data["column_metadata"] = column_metadata
421
+
248
422
  # Store LazyFrame for streaming to disk (filter happens at render time)
249
423
  # Base class will use sink_parquet() to stream without full materialization
250
424
  self._preprocessed_data["data"] = data # Keep lazy
@@ -285,40 +459,322 @@ class Table(BaseComponent):
285
459
 
286
460
  def _prepare_vue_data(self, state: Dict[str, Any]) -> Dict[str, Any]:
287
461
  """
288
- Prepare table data for Vue component.
462
+ Prepare table data for Vue component with server-side pagination.
289
463
 
290
- Returns pandas DataFrame for efficient Arrow serialization to frontend.
291
- Data is filtered based on current selection state.
464
+ Implements streaming pagination where only the current page of data
465
+ is sent to the frontend. Handles server-side sorting, column filtering,
466
+ and cross-component selection navigation.
292
467
 
293
468
  Args:
294
469
  state: Current selection state from StateManager
295
470
 
296
471
  Returns:
297
- Dict with tableData (pandas DataFrame) and _hash keys
472
+ Dict with tableData (pandas DataFrame), _hash, _pagination metadata,
473
+ and optional _navigate_to_page/_target_row_index for selection navigation
298
474
  """
475
+ import time
476
+
477
+ logger.info(f"[Table._prepare_vue_data] ===== START ===== ts={time.time()}")
478
+ logger.info(f"[Table._prepare_vue_data] cache_id={self._cache_id}")
479
+ logger.info(
480
+ f"[Table._prepare_vue_data] pagination_identifier={self._pagination_identifier}"
481
+ )
482
+ pagination_state_for_log = state.get(self._pagination_identifier)
483
+ logger.info(
484
+ f"[Table._prepare_vue_data] pagination_state={pagination_state_for_log}"
485
+ )
486
+
299
487
  # Get columns to select for projection pushdown
300
488
  columns = self._get_columns_to_select()
301
489
 
302
490
  # Get cached data (DataFrame or LazyFrame)
303
491
  data = self._preprocessed_data.get("data")
304
492
  if data is None:
305
- # Fallback to raw data if available
306
493
  data = self._raw_data
307
494
 
308
495
  # Ensure we have a LazyFrame for filtering
309
496
  if isinstance(data, pl.DataFrame):
310
497
  data = data.lazy()
311
498
 
312
- # Use cached filter+collect - returns (pandas DataFrame, hash)
313
- df_pandas, data_hash = filter_and_collect_cached(
314
- data,
315
- self._filters,
316
- state,
317
- columns=columns,
318
- filter_defaults=self._filter_defaults,
319
- )
499
+ # Apply column projection first for efficiency
500
+ if columns:
501
+ schema_names = data.collect_schema().names()
502
+ available_cols = [c for c in columns if c in schema_names]
503
+ if available_cols:
504
+ data = data.select(available_cols)
505
+
506
+ # Apply cross-component filters (from self._filters)
507
+ for identifier, column in self._filters.items():
508
+ selected_value = state.get(identifier)
509
+ # Apply default if value is None and default exists
510
+ if (
511
+ selected_value is None
512
+ and self._filter_defaults
513
+ and identifier in self._filter_defaults
514
+ ):
515
+ selected_value = self._filter_defaults[identifier]
516
+
517
+ if selected_value is None:
518
+ # No selection for this filter - return empty DataFrame
519
+ df_polars = data.head(0).collect()
520
+ data_hash = compute_dataframe_hash(df_polars)
521
+ return {
522
+ "tableData": df_polars.to_pandas(),
523
+ "_hash": data_hash,
524
+ "_pagination": {
525
+ "page": 1,
526
+ "page_size": self._page_size,
527
+ "total_rows": 0,
528
+ "total_pages": 0,
529
+ },
530
+ }
531
+
532
+ # Convert float to int for integer columns (JS numbers come as floats)
533
+ if isinstance(selected_value, float) and selected_value.is_integer():
534
+ selected_value = int(selected_value)
535
+ data = data.filter(pl.col(column) == selected_value)
536
+
537
+ # Get pagination state
538
+ pagination_state = state.get(self._pagination_identifier)
539
+ if pagination_state is None:
540
+ pagination_state = {}
541
+
542
+ page = pagination_state.get("page", 1)
543
+ page_size = pagination_state.get("page_size", self._page_size)
544
+ sort_column = pagination_state.get("sort_column")
545
+ sort_dir = pagination_state.get("sort_dir", "asc")
546
+ column_filters = pagination_state.get("column_filters", [])
547
+ go_to_request = pagination_state.get("go_to_request")
548
+
549
+ # Apply column filters from filter dialog
550
+ for col_filter in column_filters:
551
+ field = col_filter.get("field")
552
+ filter_type = col_filter.get("type")
553
+ value = col_filter.get("value")
554
+
555
+ if not field or value is None:
556
+ continue
557
+
558
+ if filter_type == "in" and isinstance(value, list):
559
+ # Categorical filter - match any of the values
560
+ data = data.filter(pl.col(field).is_in(value))
561
+ elif filter_type == ">=":
562
+ data = data.filter(pl.col(field) >= value)
563
+ elif filter_type == "<=":
564
+ data = data.filter(pl.col(field) <= value)
565
+ elif filter_type == "regex":
566
+ # Text search with regex
567
+ data = data.filter(pl.col(field).str.contains(value, literal=False))
568
+
569
+ # Apply server-side sort
570
+ if sort_column:
571
+ descending = sort_dir == "desc"
572
+ data = data.sort(sort_column, descending=descending)
573
+
574
+ # Get total row count (after filters, before pagination)
575
+ total_rows = data.select(pl.len()).collect().item()
576
+ total_pages = max(1, (total_rows + page_size - 1) // page_size)
577
+
578
+ # Handle go-to request (server-side search for row by field value)
579
+ navigate_to_page = None
580
+ target_row_index = None
581
+
582
+ if go_to_request:
583
+ go_to_field = go_to_request.get("field")
584
+ go_to_value = go_to_request.get("value")
585
+ if go_to_field and go_to_value is not None:
586
+ # Try to convert to number if applicable
587
+ try:
588
+ go_to_value = float(go_to_value)
589
+ if go_to_value.is_integer():
590
+ go_to_value = int(go_to_value)
591
+ except (ValueError, TypeError):
592
+ pass
593
+
594
+ # Find the row with row_number
595
+ search_result = (
596
+ data.with_row_index("_row_num")
597
+ .filter(pl.col(go_to_field) == go_to_value)
598
+ .select("_row_num")
599
+ .head(1)
600
+ .collect()
601
+ )
602
+
603
+ if len(search_result) > 0:
604
+ row_num = search_result["_row_num"][0]
605
+ target_page = (row_num // page_size) + 1
606
+ navigate_to_page = target_page
607
+ target_row_index = row_num % page_size
608
+ page = target_page # Jump to target page
609
+
610
+ # === Selection and Sort/Filter based navigation ===
611
+ # PURPOSE: When user sorts/filters, find where the selected row ended up and navigate there
612
+ if self._interactivity and self._pagination:
613
+ import json
614
+
615
+ import streamlit as st
616
+
617
+ # Initialize tracking dicts (per-component storage)
618
+ if _LAST_SELECTION_KEY not in st.session_state:
619
+ st.session_state[_LAST_SELECTION_KEY] = {}
620
+ if _LAST_SORT_FILTER_KEY not in st.session_state:
621
+ st.session_state[_LAST_SORT_FILTER_KEY] = {}
622
+
623
+ component_key = self._cache_id # Unique key for this table instance
624
+
625
+ # Get PREVIOUS states (from last render)
626
+ last_selections = st.session_state[_LAST_SELECTION_KEY].get(
627
+ component_key, {}
628
+ )
629
+ last_sort_filter = st.session_state[_LAST_SORT_FILTER_KEY].get(
630
+ component_key, {}
631
+ )
632
+
633
+ # Build CURRENT selection state
634
+ current_selections = {}
635
+ for identifier in self._interactivity.keys():
636
+ current_selections[identifier] = state.get(identifier)
637
+
638
+ # Build CURRENT sort/filter state
639
+ # Use JSON for column_filters to enable deep comparison of nested dicts
640
+ current_sort_filter = {
641
+ "sort_column": sort_column,
642
+ "sort_dir": sort_dir,
643
+ "column_filters_json": json.dumps(column_filters, sort_keys=True),
644
+ }
645
+
646
+ # DETECT what changed by comparing current vs previous
647
+ selection_changed = current_selections != last_selections
648
+ sort_filter_changed = current_sort_filter != last_sort_filter
649
+
650
+ # CRITICAL: Update tracking state AFTER detecting changes
651
+ # This prevents infinite loops: next render will see no change
652
+ st.session_state[_LAST_SELECTION_KEY][component_key] = current_selections
653
+ st.session_state[_LAST_SORT_FILTER_KEY][component_key] = current_sort_filter
654
+
655
+ # DECIDE whether to navigate
656
+ # - Don't override go_to navigation (user explicitly requested a row)
657
+ # - Navigate if selection changed (find new selection's page)
658
+ # - Navigate if sort/filter changed AND we have a selection (find existing selection's new page)
659
+ should_navigate = False
660
+ if navigate_to_page is None:
661
+ if selection_changed:
662
+ should_navigate = True
663
+ elif sort_filter_changed and any(
664
+ v is not None for v in current_selections.values()
665
+ ):
666
+ should_navigate = True
667
+
668
+ if should_navigate:
669
+ for identifier, column in self._interactivity.items():
670
+ selected_value = state.get(identifier)
671
+ if selected_value is not None:
672
+ # Convert float to int if needed (JS numbers come as floats)
673
+ if (
674
+ isinstance(selected_value, float)
675
+ and selected_value.is_integer()
676
+ ):
677
+ selected_value = int(selected_value)
678
+
679
+ # SEARCH for the selected row in the sorted/filtered data
680
+ # with_row_index adds position so we know which page it's on
681
+ search_result = (
682
+ data.with_row_index("_row_num")
683
+ .filter(pl.col(column) == selected_value)
684
+ .select("_row_num")
685
+ .head(1)
686
+ .collect()
687
+ )
688
+
689
+ if len(search_result) > 0:
690
+ # ROW FOUND - update page in pagination state if needed
691
+ row_num = search_result["_row_num"][0]
692
+ target_page = (row_num // page_size) + 1
693
+ if target_page != page:
694
+ # Update pagination state directly (same as Vue would)
695
+ from openms_insight.core.state import (
696
+ get_default_state_manager,
697
+ )
698
+
699
+ state_manager = get_default_state_manager()
700
+ updated_pagination = {
701
+ **pagination_state,
702
+ "page": target_page,
703
+ }
704
+ state_manager.set_selection(
705
+ self._pagination_identifier, updated_pagination
706
+ )
707
+ navigate_to_page = target_page
708
+ target_row_index = row_num % page_size
709
+ page = target_page # Use new page for slicing
710
+ else:
711
+ # ROW NOT FOUND - it was filtered out
712
+ # Update selection to first row's value AND set page to 1
713
+ if sort_filter_changed and not selection_changed:
714
+ first_row_result = (
715
+ data.select(pl.col(column)).head(1).collect()
716
+ )
717
+ if len(first_row_result) > 0:
718
+ first_value = first_row_result[column][0]
719
+ if hasattr(first_value, "item"):
720
+ first_value = first_value.item()
721
+
722
+ from openms_insight.core.state import (
723
+ get_default_state_manager,
724
+ )
725
+
726
+ state_manager = get_default_state_manager()
727
+
728
+ # Update selection to first row
729
+ state_manager.set_selection(identifier, first_value)
730
+
731
+ # Update pagination state to page 1
732
+ updated_pagination = {
733
+ **pagination_state,
734
+ "page": 1,
735
+ }
736
+ state_manager.set_selection(
737
+ self._pagination_identifier, updated_pagination
738
+ )
739
+ page = 1 # Use page 1 for slicing
740
+ break
741
+
742
+ # Clamp page to valid range
743
+ page = max(1, min(page, total_pages))
744
+
745
+ # Slice to current page
746
+ offset = (page - 1) * page_size
747
+ df_polars = data.slice(offset, page_size).collect()
748
+
749
+ # Compute hash for change detection
750
+ data_hash = compute_dataframe_hash(df_polars)
751
+
752
+ # Build result
753
+ result: Dict[str, Any] = {
754
+ "tableData": df_polars.to_pandas(),
755
+ "_hash": data_hash,
756
+ "_pagination": {
757
+ "page": page,
758
+ "page_size": page_size,
759
+ "total_rows": total_rows,
760
+ "total_pages": total_pages,
761
+ "sort_column": sort_column,
762
+ "sort_dir": sort_dir,
763
+ },
764
+ }
320
765
 
321
- return {"tableData": df_pandas, "_hash": data_hash}
766
+ if navigate_to_page is not None:
767
+ result["_navigate_to_page"] = navigate_to_page
768
+ if target_row_index is not None:
769
+ result["_target_row_index"] = target_row_index
770
+
771
+ logger.info(
772
+ f"[Table._prepare_vue_data] Returning: page={page}, total_rows={total_rows}, data_rows={len(df_polars)}"
773
+ )
774
+ logger.info(
775
+ f"[Table._prepare_vue_data] hash={data_hash[:8] if data_hash else 'None'}"
776
+ )
777
+ return result
322
778
 
323
779
  def _get_component_args(self) -> Dict[str, Any]:
324
780
  """
@@ -332,6 +788,9 @@ class Table(BaseComponent):
332
788
  if column_defs is None:
333
789
  column_defs = self._preprocessed_data.get("column_definitions", [])
334
790
 
791
+ # Get column metadata for filter dialogs (computed during preprocessing)
792
+ column_metadata = self._preprocessed_data.get("column_metadata", {})
793
+
335
794
  args: Dict[str, Any] = {
336
795
  "componentType": self._get_vue_component_name(),
337
796
  "columnDefinitions": column_defs,
@@ -340,9 +799,12 @@ class Table(BaseComponent):
340
799
  "defaultRow": self._default_row,
341
800
  # Pass interactivity so Vue knows which identifier to update on row click
342
801
  "interactivity": self._interactivity,
343
- # Pagination settings
802
+ # Pagination settings - always use server-side pagination
344
803
  "pagination": self._pagination,
345
804
  "pageSize": self._page_size,
805
+ "paginationIdentifier": self._pagination_identifier,
806
+ # Column metadata for filter dialogs (precomputed unique values, min/max)
807
+ "columnMetadata": column_metadata,
346
808
  }
347
809
 
348
810
  if self._title:
@@ -467,6 +467,26 @@ class BaseComponent(ABC):
467
467
  """
468
468
  return list(self._filters.keys())
469
469
 
470
+ def get_initial_selection(self, state: Dict[str, Any]) -> Optional[Dict[str, Any]]:
471
+ """
472
+ Compute initial selection values for this component.
473
+
474
+ Called by render_component() BEFORE Vue renders to pre-populate
475
+ selections. This prevents race conditions when multiple tables
476
+ render simultaneously - all default selections are set in Python
477
+ before any Vue component has a chance to trigger a rerun.
478
+
479
+ Override in subclasses that support default selection (e.g., Table).
480
+
481
+ Args:
482
+ state: Current selection state from StateManager
483
+
484
+ Returns:
485
+ Dict mapping identifier names to their initial values,
486
+ or None if no initial selection should be set.
487
+ """
488
+ return None
489
+
470
490
  def _get_primary_data(self) -> Optional[pl.LazyFrame]:
471
491
  """
472
492
  Get the primary data for operations.