Repository: marimo-team/marimo

Issue:
mo.stop doesn't stop dependent cells in scripts
### Describe the bug

When running the given testcase in marimo edit, the last cell doesn't execute and shows the `Ancestor stopped` message.

However, if I try to run the notebook as a scripts with `uv run python stop.py`, it fails with:
`NameError: name 'x' is not defined` 

(testcase based on the test in https://github.com/marimo-team/marimo/pull/4273)

### Will you submit a PR?

- [ ] Yes

### Environment

<details>

```
{
  "marimo": "0.13.10",
  "OS": "Linux",
  "OS Version": "6.12.26",
  "Processor": "",
  "Python Version": "3.13.1",
  "Binaries": {
    "Browser": "--",
    "Node": "v20.18.1"
  },
  "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": "missing",
    "uvicorn": "0.34.2",
    "websockets": "15.0.1"
  },
  "Optional Dependencies": {
    "loro": "1.5.1"
  },
  "Experimental Flags": {
    "lsp": true,
    "table_charts": true
  }
}```

</details>


### Code to reproduce

```python
import marimo

__generated_with = "0.13.10"
app = marimo.App(width="medium")


@app.cell
def _():
    import marimo as mo
    return (mo,)


@app.cell
def _(mo):
    mo.stop(True)

    x = 0
    return (x,)


@app.cell
def _(x):
    x
    return


if __name__ == "__main__":
    app.run()
```

Discussion:
@dmadisetti: Hmm, this seems to work for me?

```python
from typing import Any
from marimo import App

app = App()

@app.cell
def _() -> Any:
    import marimo as mo
    print("a")
    return (mo,)

@app.cell
def _(mo) -> tuple[int]:
    mo.stop(True)
    print("b")
    x = 0
    return (x,)

@app.cell
def _() -> tuple[int]:
    print("c")
    y = 1
    return (y,)

_, defs = app.run()
assert "x" not in defs
assert defs["y"] == 1
```

```txt
[main]× » python stop.py                                                                        
a
c
```

What's the expected behavior?@spasche: @dmadisetti, the idea was to access `x` in the third cell of your example.
@akshayka, thank you for the quick fix!

Solution patch:
diff --git a/docs/api/query_params.md b/docs/api/query_params.md
index d9e80a5e..c7c66ae5 100644
--- a/docs/api/query_params.md
+++ b/docs/api/query_params.md
@@ -1,5 +1,7 @@
 # Query Parameters
 
+Query parameters are key-value pairs appended to the end of a URL to pass data to the server or customize a request. 
+
 Use `mo.query_params` to access query parameters passed to the notebook. You
 can also use `mo.query_params` to set query parameters in order to keep track
 of state in the URL. This is useful for bookmarking or sharing a particular
@@ -7,6 +9,42 @@ state of the notebook while running as an application with `marimo run`.
 
 ::: marimo.query_params
 
+## Pydantic Models for Query Parameters
+
+One of the use cases for URL query parameters is to set the initial state of UI elements.
+
+Passing query parameters into a Pydantic model helps document and validate the parameters.
+
+
+```python
+import marimo as mo
+from pydantic import BaseModel, Field
+
+class MyModel(BaseModel):
+    r: int = Field(28, ge=0, le=255, description="Red Channel")
+    g: int = Field(115, ge=0, le=255, description="Green Channel")
+    b: int = Field(97, ge=0, le=255, description="Blue Channel")
+    message: str = Field("<br>", description="Some text")
+
+model = MyModel(**mo.query_params().to_dict())
+
+# UI with initial state from query params
+r_slider = mo.ui.slider(start=0, stop=255, step=1, label="R", value=model.r)
+g_slider = mo.ui.slider(start=0, stop=255, step=1, label="G", value=model.g)
+b_slider = mo.ui.slider(start=0, stop=255, step=1, label="B", value=model.b)
+```
+
+In the next cell: (see [live](https://marimo.app/l/03egkc?g=255))
+```python
+bg_color = f"rgb({r_slider.value},{g_slider.value},{b_slider.value})"
+mo.vstack([
+    r_slider, g_slider, b_slider,
+    mo.Html(model.message + bg_color).style(background_color=bg_color, text_align="center")
+])
+```
+
+When using [marimo apps mounted to FastAPI](../guides/deploying/programmatically.md), the Pydantic model can be passed into the API documentation for the main app.
+
 !!! note "CLI arguments"
 
     You can also access command-line arguments passed to the notebook using
diff --git a/docs/guides/deploying/programmatically.md b/docs/guides/deploying/programmatically.md
index 4764ad0b..d84ddc69 100644
--- a/docs/guides/deploying/programmatically.md
+++ b/docs/guides/deploying/programmatically.md
@@ -121,3 +121,52 @@ The `request` object provides access to:
 - `request.user`: User data added by authentication middleware
 - `request.url`: URL information including path, query parameters
 - `request.meta`: Metadata added by your custom middleware
+
+
+### Documenting and Validating Query Parameters
+
+When mounted apps accept [query parameters](../../api/query_params.md), it can be helpful to declare, validate, and document them with the help of a [Pydantic model](https://fastapi.tiangolo.com/tutorial/query-param-models/).
+
+If a marimo app called `notebooks/items.py` is mounted to `/items`, declaring an endpoint with the same route will take the query parameters through Pydantic model validation first, then redirect to the marimo endpoint.
+
+
+```python
+# src/main.py
+from fastapi import FastAPI, Request, Query
+from fastapi.responses import RedirectResponse
+from marimo import create_asgi_app
+from pathlib import Path
+from pydantic import BaseModel, Field
+from typing import Annotated, Literal
+from urllib.parse import urlencode
+
+
+app = FastAPI()
+
+
+class FilterParams(BaseModel):
+    limit: int = Field(100, gt=0, le=100)
+    offset: int = Field(0, ge=0)
+    order_by: Literal["created_at", "updated_at"] = "created_at"
+    tags: list[str] = []
+
+
+@app.get("/items")
+async def marimo_items(
+    request: Request, filter_query: Annotated[FilterParams, Query()]
+):
+    query_params = urlencode(filter_query.model_dump(), doseq=True)
+    return RedirectResponse(url=f"/items/?{query_params}")
+
+
+server = create_asgi_app(include_code=True, quiet=False)
+notebooks_dir = Path(__file__).parent.parent / "notebooks"
+
+for filename in notebooks_dir.iterdir():
+    if filename.suffix == ".py":
+        app_name = filename.stem
+        server = server.with_app(path=f"/{app_name}", root=filename)
+
+
+app.mount("/", server.build())
+```
\ No newline at end of file
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/frontend/src/plugins/impl/CheckboxPlugin.tsx b/frontend/src/plugins/impl/CheckboxPlugin.tsx
index b011174d..40eb7432 100644
--- a/frontend/src/plugins/impl/CheckboxPlugin.tsx
+++ b/frontend/src/plugins/impl/CheckboxPlugin.tsx
@@ -7,17 +7,23 @@ import { Checkbox } from "../../components/ui/checkbox";
 import type { CheckedState } from "@radix-ui/react-checkbox";
 import { Labeled } from "./common/labeled";
 
-export class CheckboxPlugin
-  implements IPlugin<boolean, { label: string | null }>
-{
+type T = boolean;
+
+interface Data {
+  label: string | null;
+  disabled?: boolean;
+}
+
+export class CheckboxPlugin implements IPlugin<T, Data> {
   tagName = "marimo-checkbox";
 
   validator = z.object({
     initialValue: z.boolean(),
     label: z.string().nullable(),
+    disabled: z.boolean().optional(),
   });
 
-  render(props: IPluginProps<boolean, { label: string | null }>): JSX.Element {
+  render(props: IPluginProps<T, Data>): JSX.Element {
     return <CheckboxComponent {...props} />;
   }
 }
@@ -26,7 +32,7 @@ const CheckboxComponent = ({
   value,
   setValue,
   data,
-}: IPluginProps<boolean, { label: string | null }>): JSX.Element => {
+}: IPluginProps<T, Data>): JSX.Element => {
   const onClick = (newValue: CheckedState) => {
     // unsupported state
     if (newValue === "indeterminate") {
@@ -43,6 +49,7 @@ const CheckboxComponent = ({
         checked={value}
         onCheckedChange={onClick}
         id={id}
+        disabled={data.disabled}
       />
     </Labeled>
   );
diff --git a/marimo/_plugins/ui/_impl/input.py b/marimo/_plugins/ui/_impl/input.py
index 4cca8e0f..bc1a5ce2 100644
--- a/marimo/_plugins/ui/_impl/input.py
+++ b/marimo/_plugins/ui/_impl/input.py
@@ -600,6 +600,7 @@ class checkbox(UIElement[bool, bool]):
         label (str, optional): Markdown label for the element. Defaults to "".
         on_change (Callable[[bool], None], optional): Optional callback to run when
             this element's value changes. Defaults to None.
+        disabled (bool, optional): Whether the checkbox is disabled. Defaults to False.
     """
 
     _name: Final[str] = "marimo-checkbox"
@@ -609,13 +610,16 @@ class checkbox(UIElement[bool, bool]):
         value: bool = False,
         *,
         label: str = "",
+        disabled: bool = False,
         on_change: Optional[Callable[[bool], None]] = None,
     ) -> None:
         super().__init__(
             component_name=checkbox._name,
             initial_value=value,
             label=label,
-            args={},
+            args={
+                "disabled": disabled,
+            },
             on_change=on_change,
         )
 
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(),