#6310·nicegui

Fix missing tab storage creation in the On Air handshake

Author: evnchnCreated Aug 26, 2026Updated Sep 14, 2026
Labelsbugreview

Description

On the On Air transport, app.storage.tab raises AssertionError: tab storage for <tab_id> should be created before accessing it on a page's first visit.

Air._handle_handshake (nicegui/air.py) never calls Storage._create_tab_storage, while the local Socket.IO handler _on_handshake (nicegui/nicegui.py) ends with exactly that call. The only thing that populates Storage._tabs on the Air path is copy_tab, and copy_tab is both conditional on data.get('old_tab_id') being present and a no-op unless the old ID is already in _tabs:

python
def copy_tab(self, old_tab_id: str, tab_id: str) -> None:
    if old_tab_id in self._tabs:   # <- silently does nothing otherwise
        ...

So a first-ever Air handshake for a tab leaves _tabs without an entry, and Storage.tab then trips its own assertion:

python
assert client.tab_id in self._tabs, f'tab storage for {client.tab_id} should be created before accessing it'

Spotted by @falkoschindler while reviewing #6279 (finding 4 there), and filed separately at his request because it is not caused by that PR.

Reproduction — the Air handshake sequence, run without the relay

I do not have an On Air relay to drive, so this runs the statements Air._handle_handshake executes, in order, for a handshake without a known old_tab_id, and then touches app.storage.tab the way page code would. Everything below is real library code; only the relay frame is stood in for.

python
async def test_air_handshake_leaves_no_tab_storage(user: User):
    @ui.page('/')
    def index():
        pass

    template = await user.open('/')
    client = Client(template.page, request=template.request)

    # exactly the sequence nicegui/air.py::Air._handle_handshake runs
    client.environ = {'QUERY_STRING': f'client_id={client.id}'}
    client.tab_id = 'air-tab'          # no old_tab_id -> no copy_tab, and no _create_tab_storage anywhere
    client.on_air = True
    client.handle_handshake('air-sid', 'air-doc', None)

    print('tab_id in storage._tabs:', client.tab_id in core.app.storage._tabs)
    with client:
        app.storage.tab['x'] = 1
tab_id in storage._tabs: False
AssertionError: tab storage for air-tab should be created before accessing it

The same sequence through _on_handshake ends in await core.app.storage._create_tab_storage(client.tab_id), so the local transport does not have the problem.

Why it is not caught today

tests/test_air.py does not exercise a handshake through the relay, and every tab-storage test goes through _on_handshake (directly, or via the User/Screen fixtures), which is the arm that does create the storage. So the asymmetry between the two handshake handlers is invisible to the suite.

Possible fix

Await _create_tab_storage(data['tab_id']) in Air._handle_handshake, next to the existing copy_tab call, mirroring _on_handshake. That makes the handler async, which python-socketio's relay client supports. Folding the shared register → copy_tabtab_idhandle_handshake sequence into one Client method would keep the two transports from drifting again — @falkoschindler raised that as a leave-or-take in #6279 and it was left, so it may be worth taking here instead.

I have not tried either, because I cannot verify a fix on the Air path without a relay.