Skip to content

on_mount() called twice when derived class uses await super().on_mount() #6403

@CurtisPoyton

Description

@CurtisPoyton

Summary

When a derived App class overrides on_mount() and calls await super().on_mount(), Textual dispatches the Mount event twice, causing the base class's on_mount() to execute twice.

Environment

  • Textual version: 8.0.1
  • Python version: 3.14.3
  • Operating system: Windows 11 Enterprise (10.0.26200)
  • Terminal: Windows Terminal

Expected Behavior

on_mount() should be called once per class in the MRO chain:

1. DerivedApp.on_mount() START
2. BaseApp.on_mount() (via await super().on_mount())
3. DerivedApp.on_mount() END

Actual Behavior

The base class's on_mount() is called twice:

1. DerivedApp.on_mount() START
2. BaseApp.on_mount() #1 (via await super().on_mount())
3. DerivedApp.on_mount() END
4. BaseApp.on_mount() #2 (Textual dispatches Mount again!)

Minimal Reproducer

#!/usr/bin/env python3
import asyncio
from textual.app import App, ComposeResult
from textual.widgets import Static

mount_count = 0

class BaseApp(App):
    """Base app with on_mount"""

    def compose(self) -> ComposeResult:
        yield Static("Test App")

    async def on_mount(self) -> None:
        global mount_count
        mount_count += 1
        print(f"BaseApp.on_mount() called (count: {mount_count})")

class DerivedApp(BaseApp):
    """Derived app that calls super().on_mount()"""

    async def on_mount(self) -> None:
        print("DerivedApp.on_mount() START")
        await super().on_mount()
        print("DerivedApp.on_mount() END")

async def main():
    app = DerivedApp()
    task = asyncio.create_task(app.run_async())
    await asyncio.sleep(1.5)
    task.cancel()
    try:
        await task
    except asyncio.CancelledError:
        pass

    print(f"\nResult: BaseApp.on_mount() was called {mount_count} time(s)")
    # Expected: 1, Actual: 2

if __name__ == "__main__":
    asyncio.run(main())

Output:

DerivedApp.on_mount() START
BaseApp.on_mount() called (count: 1)
DerivedApp.on_mount() END
BaseApp.on_mount() called (count: 2)

Result: BaseApp.on_mount() was called 2 time(s)

Investigation

I tested 6 different app configurations to isolate the trigger:

Configuration Result
Simple App (no inheritance) ✓ Single mount
App with mixins (no on_mount) ✓ Single mount
App with mixins (with on_mount, no super) ✓ Single mount
App with complex compose ✓ Single mount
App with super().on_mount() ✗ Double mount
Multiple mixins + super().on_mount() ✗ Double mount

The issue only occurs when:

  1. Using inheritance (base + derived)
  2. Derived class overrides on_mount()
  3. Derived class calls await super().on_mount()

Root Cause

From stack trace analysis, Textual dispatches events.Mount() from _process_messages() (app.py line 3365). When inheritance is involved, it appears to dispatch the event to both the derived and base classes separately, even though the derived class already calls the base via super().

Impact

  • Performance: Wastes 2-3 seconds per app startup in complex apps
  • Correctness: Can cause bugs if initialization isn't idempotent
  • Developer confusion: Violates expected Python inheritance behavior

Workaround

Add an idempotency guard:

async def on_mount(self) -> None:
    if hasattr(self, '_on_mount_completed'):
        return  # Skip duplicate call

    # ... initialization code ...

    self._on_mount_completed = True

Questions

  1. Is this intended Textual behavior?
  2. Should derived classes not call super().on_mount()?
  3. What's the recommended pattern for inheritance with lifecycle hooks?

Additional Context

  • Discovered in production app with 89K lines, 10 mixins
  • Reproducible in minimal example above
  • Affects all lifecycle hooks that use super()

Related

Potentially related to #2914 and #3858

textual_bug_reproducer.py

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions