Repository: Textualize/textual

Issue:
A `watch` on a reactive that is declared `init=False` fires watch method on init
[Stemming from a question on Discord](https://discord.com/channels/1026214085173461072/1033754296224841768/1185161330462838784); consider this code:

```python
from textual.app import App, ComposeResult
from textual.reactive import var
from textual.widgets import Label, Log

class SomeWidget(Label):

    test_1: var[int] = var(0)
    test_2: var[int] = var(0, init=False)

    def watch_test_1(self, was: int, into: int) -> None:
        self.screen.query_one(Log).write_line(f"test_1 {was} -> {into}")

    def watch_test_2(self, was: int, into: int) -> None:
        self.screen.query_one(Log).write_line(f"test_2 {was} -> {into}")

class InitOverrideApp(App[None]):

    def compose(self) -> ComposeResult:
        yield SomeWidget()
        yield Log()

if __name__ == "__main__":
    InitOverrideApp().run()
```

when run, it correctly logs that `test_1` changed, and correctly doesn't log that `test_2` changed. Now, if we add a `watch` on `test_2`:

```python
from textual.app import App, ComposeResult
from textual.reactive import var
from textual.widgets import Label, Log

class SomeWidget(Label):

    test_1: var[int] = var(0)
    test_2: var[int] = var(0, init=False)

    def watch_test_1(self, was: int, into: int) -> None:
        self.screen.query_one(Log).write_line(f"test_1 {was} -> {into}")

    def watch_test_2(self, was: int, into: int) -> None:
        self.screen.query_one(Log).write_line(f"test_2 {was} -> {into}")

class InitOverrideApp(App[None]):

    def compose(self) -> ComposeResult:
        yield SomeWidget()
        yield Log()

    def on_mount(self) -> None:
        def gndn() -> None:
            return
        self.watch(self.query_one(SomeWidget), "test_2", gndn)

if __name__ == "__main__":
    InitOverrideApp().run()
```

this appears to override the `init=False` declared for `test_2`, and causes `SomeWidget.watch_test_2` to fire. This can be worked around by making the `watch` setup call like this:

```python
        self.watch(self.query_one(SomeWidget), "test_2", gndn, init=False)
```

but it seems like an unintended consequence that the watch method (`SomeWidget.watch_test_2`) gets called without that.

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 302b617c4..a826849fa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](http://keepachangelog.com/)
 and this project adheres to [Semantic Versioning](http://semver.org/).
 
+## Unreleased
+
+### Fixed
+
+- Fixed duplicate watch methods being attached to DOM nodes https://github.com/Textualize/textual/pull/4030
+- Fixed using `watch` to create additional watchers would trigger other watch methods https://github.com/Textualize/textual/issues/3878
+
 ## [0.49.0] - 2024-02-07
 
 ### Fixed
diff --git a/src/textual/_types.py b/src/textual/_types.py
index 4fe929f58..75a28e7c7 100644
--- a/src/textual/_types.py
+++ b/src/textual/_types.py
@@ -31,12 +31,24 @@ CallbackType = Union[Callable[[], Awaitable[None]], Callable[[], None]]
 """Type used for arbitrary callables used in callbacks."""
 IgnoreReturnCallbackType = Union[Callable[[], Awaitable[Any]], Callable[[], Any]]
 """A callback which ignores the return type."""
-WatchCallbackType = Union[
-    Callable[[], Awaitable[None]],
-    Callable[[Any], Awaitable[None]],
+WatchCallbackBothValuesType = Union[
     Callable[[Any, Any], Awaitable[None]],
-    Callable[[], None],
-    Callable[[Any], None],
     Callable[[Any, Any], None],
 ]
+"""Type for watch methods that accept the old and new values of reactive objects."""
+WatchCallbackNewValueType = Union[
+    Callable[[Any], Awaitable[None]],
+    Callable[[Any], None],
+]
+"""Type for watch methods that accept only the new value of reactive objects."""
+WatchCallbackNoArgsType = Union[
+    Callable[[], Awaitable[None]],
+    Callable[[], None],
+]
+"""Type for watch methods that do not require the explicit value of the reactive."""
+WatchCallbackType = Union[
+    WatchCallbackBothValuesType,
+    WatchCallbackNewValueType,
+    WatchCallbackNoArgsType,
+]
 """Type used for callbacks passed to the `watch` method of widgets."""
diff --git a/src/textual/reactive.py b/src/textual/reactive.py
index c23d5a56c..ec6703835 100644
--- a/src/textual/reactive.py
+++ b/src/textual/reactive.py
@@ -16,6 +16,7 @@ from typing import (
     Generic,
     Type,
     TypeVar,
+    cast,
     overload,
 )
 
@@ -23,7 +24,13 @@ import rich.repr
 
 from . import events
 from ._callback import count_parameters
-from ._types import MessageTarget, WatchCallbackType
+from ._types import (
+    MessageTarget,
+    WatchCallbackBothValuesType,
+    WatchCallbackNewValueType,
+    WatchCallbackNoArgsType,
+    WatchCallbackType,
+)
 
 if TYPE_CHECKING:
     from .dom import DOMNode
@@ -42,6 +49,43 @@ class TooManyComputesError(ReactiveError):
     """Raised when an attribute has public and private compute methods."""
 
 
+async def await_watcher(obj: Reactable, awaitable: Awaitable[object]) -> None:
+    """Coroutine to await an awaitable returned from a watcher"""
+    _rich_traceback_omit = True
+    await awaitable
+    # Watcher may have changed the state, so run compute again
+    obj.post_message(events.Callback(callback=partial(Reactive._compute, obj)))
+
+
+def invoke_watcher(
+    watcher_object: Reactable,
+    watch_function: WatchCallbackType,
+    old_value: object,
+    value: object,
+) -> None:
+    """Invoke a watch function.
+
+    Args:
+        watcher_object: The object watching for the changes.
+        watch_function: A watch function, which may be sync or async.
+        old_value: The old value of the attribute.
+        value: The new value of the attribute.
+    """
+    _rich_traceback_omit = True
+    param_count = count_parameters(watch_function)
+    if param_count == 2:
+        watch_result = cast(WatchCallbackBothValuesType, watch_function)(
+            old_value, value
+        )
+    elif param_count == 1:
+        watch_result = cast(WatchCallbackNewValueType, watch_function)(value)
+    else:
+        watch_result = cast(WatchCallbackNoArgsType, watch_function)()
+    if isawaitable(watch_result):
+        # Result is awaitable, so we need to await it within an async context
+        watcher_object.call_next(partial(await_watcher, watcher_object, watch_result))
+
+
 @rich.repr.auto
 class Reactive(Generic[ReactiveType]):
     """Reactive descriptor.
@@ -239,7 +283,7 @@ class Reactive(Generic[ReactiveType]):
                 obj.refresh(repaint=self._repaint, layout=self._layout)
 
     @classmethod
-    def _check_watchers(cls, obj: Reactable, name: str, old_value: Any):
+    def _check_watchers(cls, obj: Reactable, name: str, old_value: Any) -> None:
         """Check watchers, and call watch methods / computes
 
         Args:
@@ -252,39 +296,6 @@ class Reactive(Generic[ReactiveType]):
         internal_name = f"_reactive_{name}"
         value = getattr(obj, internal_name)
 
-        async def await_watcher(awaitable: Awaitable) -> None:
-            """Coroutine to await an awaitable returned from a watcher"""
-            _rich_traceback_omit = True
-            await awaitable
-            # Watcher may have changed the state, so run compute again
-            obj.post_message(events.Callback(callback=partial(Reactive._compute, obj)))
-
-        def invoke_watcher(
-            watcher_object: Reactable,
-            watch_function: Callable,
-            old_value: object,
-            value: object,
-        ) -> None:
-            """Invoke a watch function.
-
-            Args:
-                watcher_object: The object watching for the changes.
-                watch_function: A watch function, which may be sync or async.
-                old_value: The old value of the attribute.
-                value: The new value of the attribute.
-            """
-            _rich_traceback_omit = True
-            param_count = count_parameters(watch_function)
-            if param_count == 2:
-                watch_result = watch_function(old_value, value)
-            elif param_count == 1:
-                watch_result = watch_function(value)
-            else:
-                watch_result = watch_function()
-            if isawaitable(watch_result):
-                # Result is awaitable, so we need to await it within an async context
-                watcher_object.call_next(partial(await_watcher, watch_result))
-
         private_watch_function = getattr(obj, f"_watch_{name}", None)
         if callable(private_watch_function):
             invoke_watcher(obj, private_watch_function, old_value, value)
@@ -294,7 +305,7 @@ class Reactive(Generic[ReactiveType]):
             invoke_watcher(obj, public_watch_function, old_value, value)
 
         # Process "global" watchers
-        watchers: list[tuple[Reactable, Callable]]
+        watchers: list[tuple[Reactable, WatchCallbackType]]
         watchers = getattr(obj, "__watchers", {}).get(name, [])
         # Remove any watchers for reactables that have since closed
         if watchers:
@@ -404,11 +415,13 @@ def _watch(
     """
     if not hasattr(obj, "__watchers"):
         setattr(obj, "__watchers", {})
-    watchers: dict[str, list[tuple[Reactable, Callable]]] = getattr(obj, "__watchers")
+    watchers: dict[str, list[tuple[Reactable, WatchCallbackType]]] = getattr(
+        obj, "__watchers"
+    )
     watcher_list = watchers.setdefault(attribute_name, [])
-    if callback in watcher_list:
+    if any(callback == callback_from_list for _, callback_from_list in watcher_list):
         return
-    watcher_list.append((node, callback))
     if init:
         current_value = getattr(obj, attribute_name, None)
-        Reactive._check_watchers(obj, attribute_name, current_value)
+        invoke_watcher(obj, callback, current_value, current_value)
+    watcher_list.append((node, callback))


Test patch:
diff --git a/tests/test_reactive.py b/tests/test_reactive.py
index df09b7969..7bddb3df7 100644
--- a/tests/test_reactive.py
+++ b/tests/test_reactive.py
@@ -598,3 +598,110 @@ async def test_set_reactive():
     app = MyApp()
     async with app.run_test():
         assert app.query_one(MyWidget).foo == "foobar"
+
+
+async def test_no_duplicate_external_watchers() -> None:
+    """Make sure we skip duplicated watchers."""
+
+    counter = 0
+
+    class Holder(Widget):
+        attr = var(None)
+
+    class MyApp(App[None]):
+        def __init__(self) -> None:
+            super().__init__()
+            self.holder = Holder()
+
+        def on_mount(self) -> None:
+            self.watch(self.holder, "attr", self.callback)
+            self.watch(self.holder, "attr", self.callback)
+
+        def callback(self) -> None:
+            nonlocal counter
+            counter += 1
+
+    app = MyApp()
+    async with app.run_test():
+        assert counter == 1
+        app.holder.attr = 73
+        assert counter == 2
+
+
+async def test_external_watch_init_does_not_propagate() -> None:
+    """Regression test for https://github.com/Textualize/textual/issues/3878.
+
+    Make sure that when setting an extra watcher programmatically and `init` is set,
+    we init only the new watcher and not the other ones, but at the same
+    time make sure both watchers work in regular circumstances.
+    """
+
+    logs: list[str] = []
+
+    class SomeWidget(Widget):
+        test_1: var[int] = var(0)
+        test_2: var[int] = var(0, init=False)
+
+        def watch_test_1(self) -> None:
+            logs.append("test_1")
+
+        def watch_test_2(self) -> None:
+            logs.append("test_2")
+
+    class InitOverrideApp(App[None]):
+        def compose(self) -> ComposeResult:
+            yield SomeWidget()
+
+        def on_mount(self) -> None:
+            def watch_test_2_extra() -> None:
+                logs.append("test_2_extra")
+
+            self.watch(self.query_one(SomeWidget), "test_2", watch_test_2_extra)
+
+    app = InitOverrideApp()
+    async with app.run_test():
+        assert logs == ["test_1", "test_2_extra"]
+        app.query_one(SomeWidget).test_2 = 73
+        assert logs.count("test_2_extra") == 2
+        assert logs.count("test_2") == 1
+
+
+async def test_external_watch_init_does_not_propagate_to_externals() -> None:
+    """Regression test for https://github.com/Textualize/textual/issues/3878.
+
+    Make sure that when setting an extra watcher programmatically and `init` is set,
+    we init only the new watcher and not the other ones (even if they were
+    added dynamically with `watch`), but at the same time make sure all watchers
+    work in regular circumstances.
+    """
+
+    logs: list[str] = []
+
+    class SomeWidget(Widget):
+        test_var: var[int] = var(0)
+
+    class MyApp(App[None]):
+        def compose(self) -> ComposeResult:
+            yield SomeWidget()
+
+        def add_first_watcher(self) -> None:
+            def first_callback() -> None:
+                logs.append("first")
+
+            self.watch(self.query_one(SomeWidget), "test_var", first_callback)
+
+        def add_second_watcher(self) -> None:
+            def second_callback() -> None:
+                logs.append("second")
+
+            self.watch(self.query_one(SomeWidget), "test_var", second_callback)
+
+    app = MyApp()
+    async with app.run_test():
+        assert logs == []
+        app.add_first_watcher()
+        assert logs == ["first"]
+        app.add_second_watcher()
+        assert logs == ["first", "second"]
+        app.query_one(SomeWidget).test_var = 73
+        assert logs == ["first", "second", "first", "second"]