zauberzeug / nicegui

Create web-based user interfaces with Python. The nice way.
https://nicegui.io
MIT License
9.95k stars 590 forks source link

A question about component update moment in the page lifecycle #50

Closed me21 closed 2 years ago

me21 commented 2 years ago

I have a page with on_connect handler. In this handler, I start some tasks with asyncio.create_task:

cls.messages_tasks = [
    asyncio.create_task(cls.handle_di_messages(cls.mqtt_client)),
    asyncio.create_task(cls.handle_ai_messages(cls.mqtt_client)),
    asyncio.create_task(cls.handle_tc_messages(cls.mqtt_client)),
    asyncio.create_task(cls.refresh_aout_values(cls.mqtt_client)),
]

Several tasks are ongoing and the last of them is single-shot. They all are async as they request and receive values from MQTT broker. What I experience is that I receive the message from MQTT broker in the last task, but the corresponding UI elements are sometimes not updated.

I suspect this is due to the asynchronous nature of the task:

Please share your thoughts on the matter - am I right? Is it possible to check it somehow?

I currently fixed it by awaiting the single-shot task instead of putting it to the common list of running tasks. Is this fix good or it's a hackish way to work around the problem?

me21 commented 2 years ago

It appears this problem affects continuously run tasks too. If they obtain a value after the page is loaded, but before the websocket is connected, then this value is not displayed in the UI too. And it won't be shown until it changes again - NiceGUI is smart enough to skip unnecessary websocket messages when the value is unchanged.

falkoschindler commented 2 years ago

Your question might be related to the breaking change in version 0.8. Before, we made use of the underlying JustPy framework to automatically update the UI after event handlers (if not actively prevented with a return False at the end). Now NiceGUI still updates most elements automatically (e.g. a label is updated after setting its text property), but some updates you might have to trigger explicitly yourself. Can you give more details about what should happen in the UI? Or maybe you can boil it down to a minimum example code?

The following example shows the current date and time after a new client has connected. Although there's a delay of 1 second (and the page is updated and rendered), setting label.text triggers an update for this UI element. Its state is sent to the client which updates the UI in the browser.

import asyncio
from datetime import datetime

from nicegui import ui

async def connect():
    await asyncio.sleep(1.0)
    label.text = datetime.now().isoformat()

with ui.page('/page', on_connect=connect):
    label = ui.label()

ui.run()

But probably you're aware of all that and the question is deeper: Is it possible that an update does not reach the client because the connection is not yet established? As far as I understand, the complete state of a page is held on the server (in Python world) for new clients. When they connect, they get the complete state of that page. I don't see how they could miss an update.

In your example the tasks are even only started after a new connection is created. So how should this connection not be ready when the MTTQ response updates the UI?

You write "but if the response comes between the page has been loaded, but before the websocket connection is up, then the component update is lost." - What do you mean with "loaded"? Doesn't the client "load" the page after establishing a websocket connection?

me21 commented 2 years ago

In your example the tasks are even only started after a new connection is created. So how should this connection not be ready when the MTTQ response updates the UI?

There are actually two connections: HTML connection and websocket connection. The on_connect handler is fired when the HTML connection is established, but obviously before the websocket connection, because the websocket is created by the page's javascript.

You write "but if the response comes between the page has been loaded, but before the websocket connection is up, then the component update is lost." - What do you mean with "loaded"? Doesn't the client "load" the page after establishing a websocket connection?

In my understanding, on_connect handler is called when the browser requests the page. This means that the websocket connection is not yet established, because it needs the page to be fully loaded in the browser, including javascript, because websocket connection is started by javascript.

So, as I see it, the process is as follows:

  1. We open new tab in the browser and open the NiceGUI webpage in it.
  2. The browser requests the page from NiceGUI server.
  3. on_connect is fired.
  4. In my example, async tasks are started.
  5. NiceGUI prepares HTML file with the initial components state and sends it to the browser.
  6. Browser starts executing the javascript on the webpage and starts websocket connection.

What would happen if the MQTT response that updates the UI arrives after 5., but before 6. is completed?

Your example with current date and time is slightly different - your on_connect handler awaits for 1 second. NiceGUI, in turn, awaits for the on_connect to finish before returning from _route_function, thus sending HTML code. In my example, the new tasks are started, so that on_connect returns immediately. These tasks will kick in when there's another await down the road, probably when the HTML response is streamed to the browser.

falkoschindler commented 2 years ago

Oh, I see! on_connect is called as part of the routing generating the HTML page, "way" before the socket connection is created. You're absolutely right: Changes to the UI that happen after on_connect returns and before the socket is connected do not reach the client.

Here is a reproduction:

import asyncio
from nicegui import ui

async def on_connect():
    label.text = 'loading...'
    asyncio.create_task(takes_a_while())

async def takes_a_while():
    await asyncio.sleep(0.1)
    label.text = 'done'

with ui.page('/page', on_connect=on_connect):
    label = ui.label()

ui.run()

With a delay of 0.1s the text "done" never appears. It's the same when removing the delay completely. But a delay of 1.0s works on my machine.

I have to think about if and how we could fix this problem. Maybe we can somehow keep track of UI updates on pages that are still connecting. These updates should be delivered once the socket is open.

As a workaround I would add a delayed UI update to make sure there is an update after the socket connected. This might be a component update (e.g. label.update()) or even a whole page update (await label.page.update()).

async def takes_a_while():
    await asyncio.sleep(0.1)
    label.text = 'done'
    await asyncio.sleep(1.0)
    label.update()
me21 commented 2 years ago

Or maybe just call on_connect only after the websocket is connected and thus the page is fully functional. Or leave on_connect as is and provide another handler on_websocket_connect...

falkoschindler commented 2 years ago

Ok, in commit https://github.com/zauberzeug/nicegui/commit/ea05fc6368e56c69ff31df73f38aec1ab160244b I introduced an on_page_ready argument for ui.page that is called when the websocket is connected. The name is borrowed from the corresponding JustPy event.

Using on_page_ready the reproduction from above is working as expected. The task is only started after the websocket is fully connected and the changing label text (from "loading..." to "done") is correctly synchronized with the client.

import asyncio
from nicegui import ui

async def on_page_ready():
    label.text = 'loading...'
    asyncio.create_task(takes_a_while())

async def takes_a_while():
    await asyncio.sleep(0.1)
    label.text = 'done'

with ui.page('/page', on_page_ready=on_page_ready):
    label = ui.label()

ui.run()
hroemer commented 2 years ago

@falkoschindler I am trying to wrap my head around the design of nicegui but don't quite get it (like the questions in #72). Maybe you could point to some documentation of how things work out here?

The example above assumes a global variable label, otherwise the update methods wouldn't work.

How are you supposed to change state of a component such as the label in your example above for any given larger UI? Where are the references/names actually "stored"?

I tried to grab them from page_stack[0].instances[1].view.components but then updates to a component need to be triggered by an await component.update() where update() is actually a synchronous method, but still needs to be awaited!?

falkoschindler commented 2 years ago

@hroemer

Maybe you could point to some documentation of how things work out here?

You've probably seen nicegui.io. That's the only documentation at the moment. More detailed explanations can be found in GitHub issues and discussions. I probably should add some links in a Q&A section.

How are you supposed to change state of a component such as the label in your example above for any given larger UI?

A function like ui.label() is actually a constructor of a NiceGUI element, which registers itself with the UI. You can store the resulting object wherever you want for later reference. Here is, for example, a "WifiButton" from the library RoSys: https://github.com/zauberzeug/rosys/blob/79c2c1dd8fabd0086fa4d4231f5bd6127291006a/rosys/system/wifi_button_.py#L38. It derives from ui.button, sets the on_click event handler and some props, defines a ui.dialog and creates a ui.timer to regularly update itself and its child elements. An explicit UI update is usually not needed. This way you can encapsulate groups of UI elements into classes and use them in higher-level code.

Where are the references/names actually "stored"?

NiceGUI itself doesn't explicitly store UI elements, but only creates JustPy views. If you need the elements, you should hold them yourself as mentioned above. Usually you shouldn't need to tap into nicegui.globals like page_stack. These are primarily for internal use.

hroemer commented 2 years ago

@hroemer thanks for your reply, the WifiButton demo really is great.

Though I am still not getting a combined sample working, including on_page_ready.

sample_page.py:

import asyncio

from nicegui import ui

class SamplePage(ui.page):
    def __init__(self) -> None:
        self.label = ui.label('Any label')
        self.label.update()
        row = ui.row()
        ui.button('+', on_click=lambda: self.add_elements(row))
        with ui.card():
            with ui.row():
                ui.label('Hello world!')
        super().__init__(route="/page", title="repr page", on_page_ready=self.on_page_ready)

    def add_elements(self, container):
        with container:
            result = 'something'
            ui.label(result)

    async def on_page_ready(self):
        self.label.text = 'loading...'
        asyncio.create_task(self.takes_a_while())

    async def takes_a_while(self):
        await asyncio.sleep(1.1)
        self.label.text = 'done'

main.py

#!/usr/bin/env python3
from jpy.ng.sample_page import SamplePage

ui = SamplePage()
ui.run(reload=True,
       host='127.0.0.1',
       port=8000,
       dark=True
       )

Accessing /page opens a blank page (#72), even though the on_page_ready event properly fires. Accessing / elements defined in __init__ are rendered. If I change the route to / I also get a blank page. If I change the run-method to reload=False I get an AttributeError: 'SamplePage' object has no attribute 'run' error!?

I would really like to hook up to the on_page_ready in a composed way like the WifiButton example. Is this feasible in any way?

falkoschindler commented 2 years ago

@hroemer Let me see...

Well well, NiceGUI definitely needs more documentation about building such custom elements or pages...

hroemer commented 2 years ago

@falkoschindler Thank you for the clarification, using the context managers is obviously the key here. The example is now working as expected.

I still don't understand the (internal) use of the page and view stacks, though. The page is added to the stacks on enter and removed on exit, thus when __init__() is done in the sample page example:

def __enter__(self):
page_stack.append(self)
view_stack.append(self.view)
return self

def __exit__(self, *_):
page_stack.pop()
view_stack.pop()

If I change the page route to root path / two routes actually get registered within the Starlette route instances. The code still runs as expected, but I fear this might have side effects.

routes

Could you please explain how "pages" are intended to be used here or specifically how to "attach" or use the default route?

Here's the current code for reference:

sample_page.py:

import asyncio

from nicegui import ui

class SamplePage(ui.page):
    def __init__(self) -> None:
        # call base initializer first:
        super().__init__(route='/', title='sample page', on_page_ready=self.on_page_ready)
        # now work within the `self` context:
        with self:
            self.label = ui.label('Any label')
            # calling `update()` is not necessary
            row = ui.row()
            ui.button('+', on_click=lambda: self.add_elements(row))
            with ui.card():
                with ui.row():
                    ui.label('Hello world!')

    def add_elements(self, container):
        with container:
            result = 'something'
            ui.label(result)

    async def on_page_ready(self):
        self.label.text = 'loading...'
        asyncio.create_task(self.takes_a_while())

    async def takes_a_while(self):
        await asyncio.sleep(1.1)
        self.label.text = 'done'

main.py:

#!/usr/bin/env python3
from nicegui import ui
from sample_page import SamplePage as sample_page

sample_page()

ui.run(reload=True,
       host='127.0.0.1',
       port=8000,
       dark=True
       )
falkoschindler commented 2 years ago

page_stack and view_stack

They are used internally to keep track of which context is currently open and, thus, where to add an element. If you write

with ui.card():
    ui.label('A')
    with ui.row():
        ui.label('B')
        ui.label('C')
    ui.label('D')

first a ui.card is added to the view_stack. Therefore, the label "A" is added to this card. After entering ui.row, the next two labels are added to the new top element, i.e. the row. After leaving and popping the row again, label "D" is added to the card. The page_stack behaves similarly.

Changing the main page

NiceGUI is intended to be easy to start with. So a three line hello world

from nicegui import ui
ui.label('Hello world!')
ui.run()

should simply work. This implies quite a lot (yes, we know, explicit is better than implicit...), e.g. how the server is started and that there is a main page at route "/". This page is automatically pushed to the page_stack and remains there. If an element is not explicitly added to a different page, it is added to "/".

Changing the main page is currently a bit tricky. You can get a reference from the first item on the page_stack:

from nicegui.globals import page_stack

main_page = page_stack[0]
main_page.dark = True
with main_page:
    ui.label('Hi!')

On the other hand it should also be ok to create a new page with route "/". Yes, then there are two routes with path "/", but since new pages insert their routes before others, it should work.

with ui.page('/', dark=True):
    ui.label('Hi!')

It also might be useful to define a MainPage class:

class MainPage(ui.page):
    def __init__(self):
        super().__init__('/', dark=True)
        with self:
            ui.label('Hi!')

MainPage()

I'll think about whether NiceGUI should automatically remove routes that are overwritten by new pages. The implementation, however, could be challenging, since we would also need to replace the page in the page_stack...