From 88092d482a3cdbb6a662d29aee9170906449bb54 Mon Sep 17 00:00:00 2001 From: jtvhd6 Date: Tue, 30 Jun 2026 21:08:35 -0500 Subject: [PATCH] Fix: make widget.loading thread-safe (closes #5108) --- CHANGELOG.md | 6 ++++++ src/textual/widget.py | 9 ++++++++- tests/test_loading.py | 22 ++++++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67652d343d..f7f9cb5b5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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 `loading` reactive not being thread-safe when set from a worker thread https://github.com/Textualize/textual/issues/5108 + ## [8.2.8] - 2026-06-30 ### Fixed diff --git a/src/textual/widget.py b/src/textual/widget.py index 63ef5da97a..f7fceda32d 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -5,6 +5,8 @@ from __future__ import annotations +import threading + from asyncio import create_task, gather, wait from collections import Counter from contextlib import asynccontextmanager @@ -1074,7 +1076,12 @@ def _watch_loading(self, loading: bool) -> None: if not self.is_mounted: self.call_later(self.set_loading, loading) else: - self.set_loading(loading) + if threading.get_ident() == self.app._thread_id: + self.set_loading(loading) + else: + # Setting `loading` from a worker thread: hop over to the + # app's thread instead of touching the UI directly. + self.app.call_from_thread(self.set_loading, loading) ExpectType = TypeVar("ExpectType", bound="Widget") diff --git a/tests/test_loading.py b/tests/test_loading.py index c3125c663a..388ca6834f 100644 --- a/tests/test_loading.py +++ b/tests/test_loading.py @@ -53,3 +53,25 @@ def compose(self) -> ComposeResult: app = LoadingApp() async with app.run_test() as pilot: await pilot.pause() + + +async def test_loading_set_from_worker_thread(): + """Regression test for #5108: setting `loading` from a background + thread should not touch the DOM directly, and should not raise.""" + + class LoadingApp(App[None]): + def compose(self) -> ComposeResult: + yield Label("hello") + + app = LoadingApp() + async with app.run_test() as pilot: + label = app.query_one(Label) + + def set_loading_from_thread() -> None: + label.loading = True + + app.run_worker(set_loading_from_thread, thread=True) + await pilot.pause() + await pilot.pause() + + assert label.loading is True