Repository: marimo-team/marimo

Issue:
webpage crashes with large mo.ui.table page_size
### Describe the bug

When I use `mo.ui.table` with a large page_size config, the browser will crash. The only solution is to restart marimo. At least some fallback / error message / hardcode the limit is preferable

### Environment

<details>

```
{
  "marimo": "0.10.12",
  "OS": "Darwin",
  "OS Version": "23.6.0",
  "Processor": "arm",
  "Python Version": "3.13.1",
  "Binaries": {
    "Browser": "131.0.6778.265",
    "Node": "v23.2.0"
  },
  "Dependencies": {
    "click": "8.1.8",
    "docutils": "0.21.2",
    "itsdangerous": "2.2.0",
    "jedi": "0.19.2",
    "markdown": "3.7",
    "narwhals": "1.21.1",
    "packaging": "24.2",
    "psutil": "6.1.1",
    "pygments": "2.19.1",
    "pymdown-extensions": "10.14",
    "pyyaml": "6.0.2",
    "ruff": "0.9.1",
    "starlette": "0.45.2",
    "tomlkit": "0.13.2",
    "typing-extensions": "4.12.2",
    "uvicorn": "0.34.0",
    "websockets": "14.1"
  },
  "Optional Dependencies": {
    "anywidget": "0.9.13",
    "duckdb": "1.1.3",
    "polars": "1.18.0",
    "pyarrow": "18.1.0"
  }
}
```

</details>


### Code to reproduce

```.py
@app.cell
def _(mo):
    random = mo.sql(
        f"""
        SELECT 
            1, 2, 3, 4, 5
        FROM generate_series(1,10000)
        """
    )
    return (random,)


@app.cell
def _(mo, random):
    mo.ui.table(random, page_size=10000)
    return
```

Hints from issue comments:
@mscolnick: My guess is from rendering react. We could add row virtualization ([example](https://tanstack.com/table/v8/docs/framework/react/examples/virtualized-rows)) when rows are e.g. > 500@akshayka: In the meantime I am totally okay with raising a ValueError in Python when the page size is too big (eg, greater than 200)@Light2Dark: Hey, sorry have been quite occupied recently. I tested Myles's solution w Marimo and yes it seems to be really smooth! This would add @tanstack/react-virtual lib.

Are we opting for this solution? Instead of hardcoding a page size limit. I imagine either solution is fine for most users 🤔@mscolnick: @Light2Dark - might be nice to avoid the complexity of virtualization and whatever other requests come up (not sure how complex your solution was, the additional dependency is ok).

I think a ValueError with "Table limited to 200 rows. If you'd like to this increased, please file an issue <github-url>". And then park this branch for later?

Open to thoughts, especially if the solution does carry a maintenance cost (still works with column pinning and other features).

@Light2Dark: yeap agreed, there is a decent amount of refactoring needed with virtualization. Add tableContainer, grid display. And the table component is used in many places.@mscolnick: @Light2Dark have you started the `ValueError`? (no worries if so) if not, @Haleshot was going to try to take a stab at it.@Light2Dark: ah 😅, I finished it but wanted to test in the morning. But feel free to just take it up next time @Haleshot :)@Haleshot: > ah 😅, I finished it but wanted to test in the morning. But feel free to just take it up next time [@Haleshot](https://github.com/Haleshot) :)

Ha no issues!

Solution patch:
diff --git a/docs/guides/editor_features/hotkeys.md b/docs/guides/editor_features/hotkeys.md
index 9f1799cd..c29f6da3 100644
--- a/docs/guides/editor_features/hotkeys.md
+++ b/docs/guides/editor_features/hotkeys.md
@@ -36,6 +36,8 @@ You can find a list of available hotkeys below:
 | `cell.unfold`               |
 | `cell.unfoldAll`            |
 | `cell.viewAsMarkdown`       |
+| `completion.moveDown`        |
+| `completion.moveUp`          |
 | `global.commandPalette`     |
 | `global.focusBottom`        |
 | `global.focusTop`           |
diff --git a/frontend/src/core/codemirror/cm.ts b/frontend/src/core/codemirror/cm.ts
index 43a3358f..4b044cce 100644
--- a/frontend/src/core/codemirror/cm.ts
+++ b/frontend/src/core/codemirror/cm.ts
@@ -173,8 +173,7 @@ export const basicBundle = (opts: CodeMirrorSetupOpts): Extension[] => {
         preventDefault: true,
       },
       {
-        key: "Alt-j",
-        mac: "Ctrl-j",
+        key: hotkeys.getHotkey("completion.moveDown").key,
         run: (cm) => {
           if (completionStatus(cm.state) !== null) {
             moveCompletionSelection(true)(cm);
@@ -185,8 +184,7 @@ export const basicBundle = (opts: CodeMirrorSetupOpts): Extension[] => {
         preventDefault: true,
       },
       {
-        key: "Alt-k",
-        mac: "Ctrl-k",
+        key: hotkeys.getHotkey("completion.moveUp").key,
         run: (cm) => {
           if (completionStatus(cm.state) !== null) {
             moveCompletionSelection(false)(cm);
diff --git a/frontend/src/core/hotkeys/hotkeys.ts b/frontend/src/core/hotkeys/hotkeys.ts
index aac24ef1..8a06b18f 100644
--- a/frontend/src/core/hotkeys/hotkeys.ts
+++ b/frontend/src/core/hotkeys/hotkeys.ts
@@ -344,6 +344,16 @@ const DEFAULT_HOT_KEY = {
     group: "Navigation",
     key: "F12",
   },
+  "completion.moveDown": {
+    name: "Move completion selection down",
+    group: "Editing",
+    key: "Ctrl-j",
+  },
+  "completion.moveUp": {
+    name: "Move completion selection up",
+    group: "Editing",
+    key: "Ctrl-k",
+  },
 } satisfies Record<string, Hotkey>;
 
 export type HotkeyAction = keyof typeof DEFAULT_HOT_KEY;
diff --git a/marimo/_plugins/ui/_impl/data_editor.py b/marimo/_plugins/ui/_impl/data_editor.py
index e2ffe629..eca1ed2b 100644
--- a/marimo/_plugins/ui/_impl/data_editor.py
+++ b/marimo/_plugins/ui/_impl/data_editor.py
@@ -25,6 +25,7 @@ from marimo import _loggers
 from marimo._output.rich_help import mddoc
 from marimo._plugins.ui._core.ui_element import UIElement
 from marimo._plugins.ui._impl.tables.utils import get_table_manager
+from marimo._plugins.validators import validate_page_size
 
 LOGGER = _loggers.marimo_logger()
 
@@ -142,6 +143,7 @@ class data_editor(
             ]
         ] = None,
     ) -> None:
+        validate_page_size(page_size)
         table_manager = get_table_manager(data)
 
         size = table_manager.get_num_rows()
diff --git a/marimo/_plugins/ui/_impl/dataframes/dataframe.py b/marimo/_plugins/ui/_impl/dataframes/dataframe.py
index c346ec67..b39dba96 100644
--- a/marimo/_plugins/ui/_impl/dataframes/dataframe.py
+++ b/marimo/_plugins/ui/_impl/dataframes/dataframe.py
@@ -36,7 +36,10 @@ from marimo._plugins.ui._impl.tables.table_manager import (
 from marimo._plugins.ui._impl.tables.utils import (
     get_table_manager,
 )
-from marimo._plugins.validators import validate_no_integer_columns
+from marimo._plugins.validators import (
+    validate_no_integer_columns,
+    validate_page_size,
+)
 from marimo._runtime.functions import EmptyArgs, Function
 from marimo._utils.memoize import memoize_last_value
 from marimo._utils.parse_dataclass import parse_raw
@@ -141,6 +144,7 @@ class dataframe(UIElement[Dict[str, Any], DataFrameType]):
         self._error: Optional[str] = None
         self._last_transforms = Transformations([])
         self._page_size = page_size or 5  # Default to 5 rows (.head())
+        validate_page_size(self._page_size)
 
         super().__init__(
             component_name=dataframe._name,
diff --git a/marimo/_plugins/ui/_impl/table.py b/marimo/_plugins/ui/_impl/table.py
index 0df9236a..d62837ff 100644
--- a/marimo/_plugins/ui/_impl/table.py
+++ b/marimo/_plugins/ui/_impl/table.py
@@ -38,7 +38,10 @@ from marimo._plugins.ui._impl.tables.table_manager import (
 )
 from marimo._plugins.ui._impl.tables.utils import get_table_manager
 from marimo._plugins.ui._impl.utils.dataframe import ListOrTuple, TableData
-from marimo._plugins.validators import validate_no_integer_columns
+from marimo._plugins.validators import (
+    validate_no_integer_columns,
+    validate_page_size,
+)
 from marimo._runtime.functions import EmptyArgs, Function
 from marimo._utils.narwhals_utils import unwrap_narwhals_dataframe
 
@@ -254,6 +257,7 @@ class table(UIElement[List[str], Union[List[JSONType], IntoDataFrame]]):
         _internal_total_rows: Optional[Union[int, Literal["too_many"]]] = None,
     ) -> None:
         validate_no_integer_columns(data)
+        validate_page_size(page_size)
 
         # The original data passed in
         self._data = data
diff --git a/marimo/_plugins/validators.py b/marimo/_plugins/validators.py
index 935d24b6..83d3b5fa 100644
--- a/marimo/_plugins/validators.py
+++ b/marimo/_plugins/validators.py
@@ -77,3 +77,11 @@ def validate_no_integer_columns(df: Any) -> None:
             "Please use strings for column names.",
             stacklevel=3,
         )
+
+
+# issue: https://github.com/marimo-team/marimo/issues/3407
+def validate_page_size(page_size: int) -> None:
+    if page_size > 200:
+        raise ValueError(
+            "Page size limited to 200 rows. If you'd like this increased, please file an issue"
+        )


Test patch:
diff --git a/tests/_plugins/ui/_impl/dataframes/test_dataframe.py b/tests/_plugins/ui/_impl/dataframes/test_dataframe.py
index 353120de..6f746b4e 100644
--- a/tests/_plugins/ui/_impl/dataframes/test_dataframe.py
+++ b/tests/_plugins/ui/_impl/dataframes/test_dataframe.py
@@ -217,6 +217,13 @@ class TestDataframes:
         assert search_result.total_rows == 100
         assert search_result.data == result.url
 
+    @staticmethod
+    def test_dataframe_too_large_page_size() -> None:
+        df = pd.DataFrame({"A": range(300)})
+        with pytest.raises(ValueError) as e:
+            _ = ui.dataframe(df, page_size=201)
+        assert "limited to 200 rows" in str(e.value)
+
     @staticmethod
     def test_dataframe_with_non_string_column_names() -> None:
         df = pd.DataFrame(
diff --git a/tests/_plugins/ui/_impl/test_data_editor.py b/tests/_plugins/ui/_impl/test_data_editor.py
index c5028914..f5416ae5 100644
--- a/tests/_plugins/ui/_impl/test_data_editor.py
+++ b/tests/_plugins/ui/_impl/test_data_editor.py
@@ -394,6 +394,15 @@ def test_data_editor_with_custom_pagination():
     assert editor._component_args["page-size"] == 25
 
 
+@pytest.mark.skipif(
+    not DependencyManager.polars.has(), reason="Polars not installed"
+)
+def test_data_editor_with_too_large_pagesize():
+    data = [{"A": i} for i in range(300)]
+    with pytest.raises(ValueError):
+        _ = data_editor(data=data, page_size=201)
+
+
 @pytest.mark.skipif(
     not DependencyManager.polars.has(), reason="Polars not installed"
 )
diff --git a/tests/_plugins/ui/_impl/test_table.py b/tests/_plugins/ui/_impl/test_table.py
index e39673c4..ae564304 100644
--- a/tests/_plugins/ui/_impl/test_table.py
+++ b/tests/_plugins/ui/_impl/test_table.py
@@ -396,6 +396,13 @@ def test_table_with_too_many_rows_gets_clamped() -> None:
     assert len(table._component_args["data"]) == 10
 
 
+def test_table_too_large_pagesize_throws_error() -> None:
+    data = {"a": list(range(20_002))}
+    with pytest.raises(ValueError) as e:
+        _ = ui.table(data, page_size=201)
+    assert "limited to 200 rows" in str(e.value)
+
+
 def test_can_get_second_page() -> None:
     data = {"a": list(range(40))}
     table = ui.table(data)