Repository: marimo-team/marimo

Issue:
`altair_chart` doesn't accept `LayerChart` according to types but many examples use layers
### Describe the bug

`altair_chart` works with LayerChart and many examples on the altair site use LayerChart. I think the type just needs to get added to the accepted types.

### Will you submit a PR?

- [ ] Yes

### Environment

<details>

```
{
  "marimo": "0.13.10",
  "OS": "Darwin",
  "OS Version": "24.4.0",
  "Processor": "arm",
  "Python Version": "3.12.0",
  "Binaries": {
    "Browser": "136.0.7103.114",
    "Node": "v22.15.0"
  },
  "Dependencies": {
    "click": "8.1.8",
    "docutils": "0.21.2",
    "itsdangerous": "2.2.0",
    "jedi": "0.19.2",
    "markdown": "3.8",
    "narwhals": "1.39.1",
    "packaging": "25.0",
    "psutil": "7.0.0",
    "pygments": "2.19.1",
    "pymdown-extensions": "10.15",
    "pyyaml": "6.0.2",
    "starlette": "0.46.2",
    "tomlkit": "0.13.2",
    "typing-extensions": "4.13.2",
    "uvicorn": "0.34.2",
    "websockets": "15.0.1"
  },
  "Optional Dependencies": {
    "altair": "5.5.0",
    "duckdb": "1.2.2",
    "nbformat": "5.10.4",
    "openai": "1.79.0",
    "pandas": "2.2.3",
    "polars": "1.29.0",
    "pyarrow": "20.0.0",
    "loro": "1.5.1",
    "pytest": "8.3.5",
    "python-lsp-ruff": "2.2.2",
    "python-lsp-server": "1.12.2",
    "ruff": "0.11.10",
    "sqlglot": "26.17.1"
  },
  "Experimental Flags": {}
}
(trader) ```

</details>


### Code to reproduce

# if you put together any two charts it seems like it creates a LayerChart instead of Chart.

    rule: Chart = base.mark_rule().encode(
        y=alt.Y("low:Q", title="Price", scale=alt.Scale(zero=False)),
        y2="high:Q",
    )
    bar: Chart = base.mark_bar().encode(
        y="open:Q",
        y2="close:Q",
    )
    chart: LayerChart = rule + bar # <-- this one becomes a LayerChart

Discussion:

Solution patch:
diff --git a/examples/running_as_a_script/textual_app.py b/examples/running_as_a_script/textual_app.py
new file mode 100644
index 00000000..cc7a1ac6
--- /dev/null
+++ b/examples/running_as_a_script/textual_app.py
@@ -0,0 +1,57 @@
+# /// script
+# requires-python = ">=3.13"
+# dependencies = [
+#     "marimo",
+#     "textual==3.2.0",
+# ]
+# ///
+"""
+Marimo wrapper for the base Textual app example.
+Provides a basic clock widget to show the current time.
+run with `python textual_app.py`
+"""
+
+import marimo
+
+__generated_with = "0.13.10"
+app = marimo.App()
+
+with app.setup:
+    import marimo as mo
+    from datetime import datetime
+
+    from textual.app import App, ComposeResult
+    from textual.widgets import Digits
+
+
+@app.class_definition
+class ClockApp(App):
+    CSS = """
+    Screen { align: center middle; }
+    Digits { width: auto; }
+    """
+
+    def compose(self) -> ComposeResult:
+        yield Digits("")
+
+    def on_ready(self) -> None:
+        self.update_clock()
+        self.set_interval(1, self.update_clock)
+
+    def update_clock(self) -> None:
+        clock = datetime.now().time()
+        self.query_one(Digits).update(f"{clock:%T}")
+
+
+@app.cell
+def _():
+    app = ClockApp()
+    if mo.app_meta().mode == "script":
+        app.run()
+    # App gives back a basic repr
+    app
+    return
+
+
+if __name__ == "__main__":
+    app.run()
diff --git a/marimo/_output/formatters/altair_formatters.py b/marimo/_output/formatters/altair_formatters.py
index 543a46e7..c2d10b73 100644
--- a/marimo/_output/formatters/altair_formatters.py
+++ b/marimo/_output/formatters/altair_formatters.py
@@ -2,7 +2,7 @@
 from __future__ import annotations
 
 import json
-from typing import TYPE_CHECKING, Literal
+from typing import TYPE_CHECKING, Literal, Union
 
 from marimo._config.config import Theme
 from marimo._messaging.mimetypes import KnownMimeType, MimeBundleOrTuple
@@ -34,7 +34,9 @@ class AltairFormatter(FormatterFactory):
         register_transformers()
 
         @formatting.formatter(altair.TopLevelMixin)
-        def _show_chart(chart: altair.Chart) -> tuple[KnownMimeType, str]:
+        def _show_chart(
+            chart: Union[altair.Chart, altair.LayerChart],
+        ) -> tuple[KnownMimeType, str]:
             import altair as alt
 
             # Try to get the _repr_mimebundle_ method from the chart
@@ -104,7 +106,9 @@ class AltairFormatter(FormatterFactory):
 # This is only needed since it seems that altair does not
 # handle this internally.
 # https://github.com/marimo-team/marimo/issues/2302
-def _apply_embed_options(chart: altair.Chart) -> altair.Chart:
+def _apply_embed_options(
+    chart: Union[altair.Chart, altair.LayerChart],
+) -> Union[altair.Chart, altair.LayerChart]:
     import altair as alt
 
     # Respect user-set embed options
@@ -124,7 +128,7 @@ def _apply_embed_options(chart: altair.Chart) -> altair.Chart:
 
 
 def chart_to_json(
-    chart: altair.Chart,
+    chart: Union[altair.Chart, altair.LayerChart],
     spec_format: Literal["vega", "vega-lite"] = "vega-lite",
     validate: bool = True,
 ) -> str:
diff --git a/marimo/_plugins/ui/_impl/altair_chart.py b/marimo/_plugins/ui/_impl/altair_chart.py
index 90c0382d..c0d94dac 100644
--- a/marimo/_plugins/ui/_impl/altair_chart.py
+++ b/marimo/_plugins/ui/_impl/altair_chart.py
@@ -294,7 +294,7 @@ class altair_chart(UIElement[ChartSelection, ChartDataType]):
 
     def __init__(
         self,
-        chart: altair.Chart,
+        chart: Union[altair.Chart, altair.LayerChart],
         chart_selection: Literal["point"] | Literal["interval"] | bool = True,
         legend_selection: list[str] | bool = True,
         *,
@@ -420,7 +420,7 @@ class altair_chart(UIElement[ChartSelection, ChartDataType]):
 
     @staticmethod
     def _get_dataframe_from_chart(
-        chart: altair.Chart,
+        chart: Union[altair.Chart, altair.LayerChart],
     ) -> Optional[ChartDataType]:
         if not isinstance(chart.data, str):
             return cast(ChartDataType, chart.data)
@@ -578,7 +578,9 @@ class altair_chart(UIElement[ChartSelection, ChartDataType]):
         raise RuntimeError("Setting the value of a UIElement is not allowed.")
 
 
-def maybe_make_full_width(chart: altair.Chart) -> altair.Chart:
+def maybe_make_full_width(
+    chart: Union[altair.Chart, altair.LayerChart],
+) -> Union[altair.Chart, altair.LayerChart]:
     import altair
 
     try:
@@ -596,11 +598,16 @@ def maybe_make_full_width(chart: altair.Chart) -> altair.Chart:
         return chart
 
 
-def _has_selection_param(chart: altair.Chart) -> bool:
+def _has_selection_param(
+    chart: Union[altair.Chart, altair.LayerChart],
+) -> bool:
     import altair as alt
 
+    if not hasattr(chart, "params"):
+        return False
+
     try:
-        for param in chart.params:
+        for param in chart.params:  # type: ignore
             try:
                 if isinstance(
                     param,
@@ -615,11 +622,16 @@ def _has_selection_param(chart: altair.Chart) -> bool:
     return False
 
 
-def _has_legend_param(chart: altair.Chart) -> bool:
+def _has_legend_param(
+    chart: Union[altair.Chart, altair.LayerChart],
+) -> bool:
     import altair as alt
 
+    if not hasattr(chart, "params"):
+        return False
+
     try:
-        for param in chart.params:
+        for param in chart.params:  # type: ignore
             try:
                 if isinstance(
                     param,
diff --git a/marimo/_runtime/app/script_runner.py b/marimo/_runtime/app/script_runner.py
index 0456b2f1..f42841bc 100644
--- a/marimo/_runtime/app/script_runner.py
+++ b/marimo/_runtime/app/script_runner.py
@@ -44,7 +44,7 @@ class AppScriptRunner:
     ) -> None:
         self.app = app
         self.filename = filename
-        self.cells_cancelled: dict[CellId_t, set[CellId_t]] = {}
+        self.cells_cancelled: set[CellId_t] = set()
         self._glbls = glbls if glbls else {}
         self.cells_to_run = [
             cid
@@ -55,15 +55,16 @@ class AppScriptRunner:
         self._executor = get_executor(ExecutionConfig())
 
     def _cancel(self, cell_id: CellId_t) -> None:
-        self.cells_cancelled[cell_id] = set(
+        cancelled = set(
             cid
             for cid in dataflow.transitive_closure(
                 self.app.graph, set([cell_id])
             )
             if cid in self.cells_to_run
         )
-        for cid in self.cells_cancelled[cell_id]:
+        for cid in cancelled:
             self.app.graph.cells[cid].set_run_result_status("cancelled")
+        self.cells_cancelled |= cancelled
 
     def _run_synchronous(
         self,
@@ -79,6 +80,8 @@ class AppScriptRunner:
             outputs: dict[CellId_t, Any] = {}
             while self.cells_to_run:
                 cid = self.cells_to_run.pop(0)
+                if cid in self.cells_cancelled:
+                    continue
                 # Set up has already run in this case.
                 if cid == CellId_t(SETUP_CELL_NAME):
                     for hook in post_execute_hooks:
@@ -119,6 +122,9 @@ class AppScriptRunner:
 
             while self.cells_to_run:
                 cid = self.cells_to_run.pop(0)
+                if cid in self.cells_cancelled:
+                    continue
+
                 if cid == CellId_t(SETUP_CELL_NAME):
                     for hook in post_execute_hooks:
                         hook()


Test patch:
diff --git a/tests/_ast/test_app.py b/tests/_ast/test_app.py
index db0c0e5b..c14877c5 100644
--- a/tests/_ast/test_app.py
+++ b/tests/_ast/test_app.py
@@ -492,6 +492,73 @@ class TestApp:
         assert "x" not in defs
         assert defs["y"] == 1
 
+    @staticmethod
+    def test_run_mo_stop_descendant() -> None:
+        app = App()
+
+        @app.cell
+        def _() -> Any:
+            import marimo as mo
+            return (mo,)
+
+        @app.cell
+        def _(mo) -> tuple[int]:
+            mo.stop(True)
+            x = 0
+            return (x,)
+
+        @app.cell
+        def _(x) -> tuple[int]:
+            y = 1
+            x
+            return
+
+        _, defs = app.run()
+        assert "x" not in defs
+        assert "y" not in defs
+
+    @staticmethod
+    def test_run_mo_stop_descendant_multiple() -> None:
+        app = App()
+
+        @app.cell
+        def _() -> Any:
+            import marimo as mo
+            return (mo,)
+
+        @app.cell
+        def _(mo) -> tuple[int]:
+            mo.stop(True)
+            x = 0
+            return (x,)
+
+        @app.cell
+        def _(mo) -> tuple[int]:
+            mo.stop(True)
+            y = 0
+            return (y,)
+
+
+        @app.cell
+        def _(x) -> tuple[int]:
+            x
+            a = 0
+            return
+
+        @app.cell
+        def _(y) -> tuple[int]:
+            y
+            b = 0
+            return
+
+
+        _, defs = app.run()
+        assert "x" not in defs
+        assert "y" not in defs
+        assert "a" not in defs
+        assert "b" not in defs
+
+
     @staticmethod
     def test_run_mo_stop_async() -> None:
         app = App()
@@ -516,6 +583,31 @@ class TestApp:
         assert "x" not in defs
         assert defs["y"] == 1
 
+    @staticmethod
+    def test_run_mo_stop_descendant_async() -> None:
+        app = App()
+
+        @app.cell
+        def _() -> Any:
+            import marimo as mo
+            return (mo,)
+
+        @app.cell
+        def _(mo) -> tuple[int]:
+            mo.stop(True)
+            x = 0
+            return (x,)
+
+        @app.cell
+        async def _(x) -> tuple[int]:
+            y = 1
+            x
+            return
+
+        _, defs = app.run()
+        assert "x" not in defs
+        assert "y" not in defs
+
 
     @pytest.mark.skipif(
         condition=not DependencyManager.matplotlib.has(),