Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce API to build Single Page Applications (SPAs) #2811

Draft
wants to merge 103 commits into
base: main
Choose a base branch
from

Conversation

Alyxion
Copy link
Contributor

@Alyxion Alyxion commented Apr 3, 2024

NiceGUI is for us at Lechler a really awesome solution and step forward from Streamlit in regards of the visualization of live and streaming data as it puts the dev far more in control of which sub elements and page regions are updated when.

On the other hand it is still lacking three for us very crucial features Streamlit offers:

  • A persistent WebSocket connection for the whole user session (per tab)
  • A data storage possibility per tab to enable the user to create multiple app instances.
  • In-memory storage of complex objects (e.g. pandas data tables)

image

This (still work in progress) pull request tries to resolve at least most of the points above. It shall not yet resolve the situation that a user has an unstable internet connection and thus looses the connection to a server completely and needs to reconnect.

Persistent connection

In a scenario where you want to serve your NiceGUI solution not to hundreds of users there is after a certain point no way around scaling the solution over multiple processes, CPUs or over multiple servers.

If you need to load/keep alive large amounts of data per user w/o involving an external database this requires that the whole user session, even between page changes, is bound to one single process on one specific server. Streamlits uses a SPA approach here thus it creates a WebSockets connection once and all follow-up page and tab changes are just virtual thus changing the URL and browser history in the browser using pushstate but never really loading a new page using GET.

As discussed in the Add app.storage.tab or similar (1308) and in Discord there are several use cases where this is crucial to retain in-memory data on a "per session" basis, see below, which consequently requires that there is such a session in the first place.

Per tab storage

A data storage possibility per tab is cruicial to enable the user to create multiple app instances with different login credentials, configurations and views on a per tab basis. This is on purpose volatile so that user-credentials, critical business data etc. are gone once the browser tab was closed and the connection timed out. This shall match the current behavior of st.session_state.

In-memory storage of complex objects

The possibility to store living, non-JSON compatible objects such as Pandas tables, ML model weights etc. on a "per tab" basis and make them as easy accessible among different pages, global helper classes etc. as currently app.storage.user.

Update: Extracted the app.storage.session feature into a separate pull request 2820

@Alyxion Alyxion changed the title Support for per browser-tab session data and a persistent per-session between page changes Support for per browser-tab session data and a persistent per-session connection between page changes Apr 3, 2024
Michael Ikemann and others added 11 commits April 3, 2024 14:36
Integrated single page app into Client.open so navigation to SPA pages is redirected.
Fixed bug with forward and backwards navigation between SPA pages.
Collecting data from original pages to able to apply the original page title.
Integrated single page app into Client.open so navigation to SPA pages is redirected.
Fixed bug with forward and backwards navigation between SPA pages.
Collecting data from original pages to able to apply the original page title.
Fixed a bug which could occur when open was called before the UI was set up
…s registry in Client.

General clean-up
Added titles to sample app
Added docu to SPA
…the structure of the root page, the possibility to override the class and implement custom routing and to react to the creation of sessions.

* Added samples for the single page router
* Refactored the Login sample with the new possibilities and making use of Pydantic as an example for a cleaner code base
@rodja
Copy link
Member

rodja commented Apr 4, 2024

Thank you @Alyxion. There are a lot of interesting things in here. While you are right that a certain kind of applications my want to combine SPA and "per tab storage", it would make review and discussions simpler if you could create two separate pull requests for these features.

…in the current Client instance - which in practice means "per browser tab".
@Alyxion
Copy link
Contributor Author

Alyxion commented Apr 4, 2024

Hello @rodja, sure. As this Pull Request here is anyway just a draft yet and the amount of changes of the per-tab data was manageable I extracted the lines into the separate request #2820 and added a unit test to it. As this one is dependent on the other I will keep it the way it is for now.

@Alyxion
Copy link
Contributor Author

Alyxion commented Apr 5, 2024

Added support for query and URL path parameters such as required by the modularization example.

https://github.com/Alyxion/nicegui/blob/feature/client_data/examples/modularization/main.py works really like a charm now in regards of user experience when switching pages.

At the moment it still throws a "Found top level layout element "Header" inside element "SinglePageRouterFrame". Top level layout elements should not be nested but must be direct children of the page content. This will be raising an exception in NiceGUI 1.5" warning due to the fact that it does not know yet that it actually is in the page content in that case.

Update: Fixed the warning. PageLayout now also accepts the SPA root as valid parent for top level elements.

@rodja
Copy link
Member

rodja commented Jun 22, 2024

I spend quite some time with the code. As you can see I simplified the authentication_spa example, fixed some typing problems and had to update the formatting here and there. Are you using the formatting rules described in the CONTRIBUTING.md?

I also did an rough experiment to merge outlets and views: Alyxion#3. The main idea is this: if we make the yield statement in outlets optional we could get rid of the views. After that outlet may be better called view or content.

The thing I struggled the most with was the routing. I still do not get the difference between SinglePageRouter and SinglePageRouterConfig. Both have add_view, resolve_target and navigate_to methods. That feels like a code smell.

Also I it seems that the url returned by the on_navigate-handler is not written to the url bar. See this small example where surfing to / shows the right content but the browser url does not change to /main:

@ui.outlet('/', on_navigate=lambda _: '/main')
def layout():
    ui.label('main layout')
    yield

@layout.view('/main')
def main_content():
    ui.label('main content')

I also put this in a test called test_routing_url. And I think we need to write many more tests to ensure the code is working as expected.

@Alyxion
Copy link
Contributor Author

Alyxion commented Jun 22, 2024

First of all thank you very much for all the enhancements, really looking forward to review them, will get a chance to do so next Friday, travelling a lot next week unfortunately. But I quickly want to come back to all questions.

I spend quite some time with the code. As you can see I simplified the authentication_spa example, fixed some typing problems and had to update the formatting here and there. Are you using the formatting rules described in the CONTRIBUTING.md?

I also did an rough experiment to merge outlets and views: Alyxion#3. The main idea is this: if we make the yield statement in outlets optional we could get rid of the views. After that outlet may be better called view or content.

The thing I struggled the most with was the routing. I still do not get the difference between SinglePageRouter and SinglePageRouterConfig. Both have add_view, resolve_target and navigate_to methods. That feels like a code smell.

May be the naming is a bit unlucky and we should reconsider it, but to put it into two lines:

  • SinglePageRouterConfig = Created instantly on application/server start and only a single, global instance per outlet exists for all users and basically says "There is e.g. a page /myapp" which you could visit. This is what effectively calls the classic NiceGUI's @page decorator, thus "reserves" the route and everything in that path.
  • SinglePageRouter + SinglePageRouterFrame always come in a "a team" on a per connection basis, like controller + view if you want so. They are exchanging (like int the old SPA example) the actual content. E.g. a SinglePageRouterConfig defines /myapp and any user connects to it thus gets "his" own UI element hiearchy then the root object of this hierarchy starts with these two objects. If there is a nested outlet within /myapp/suboutlet then there will be two instances of each... per "client connection / opened page".

Regarding the add_view, resolve_target and navigate_to in SinglePageRouter.

navigate_to is actually more or less from the original SPA example. It tells the SinglePageRouter to find the builder function and exchanging the content.

resolve_target is basically just forwarding the URL resolving to the SinglePageRouterConfig which originally created it. It though also shall give the chance to customize the behavior on a per-instance basis, see add_view.

add_view was not intended to make it into the final PR and shall just be a place holder as I did not find the time to discuss it with you and/or refactor it. The overall goal here was to provide a way to also build object oriented SPAs rather than "C style".

The most convenient way here would be either a decorator and/or clearly defined naming scheme... like for Pydantic models... so... assuming you don't want any fancy custom layout but just a performant SPA something like this were enough:

class MyMailApp:
    def __init__(....):
        self.my_mail_ms_graph_access_token = ... # user specific data

    def index():
        ui.label(....)
        .... some link to "/inbox"
    
    def inbox():
       ... content of sub page....
       
ui.outlet.register("/mail", router_class=MyMailApp)
ui.run()

As written above already I think this "C style" is great for short tutorials and beginners, but if you really want to build large apps... say a complex UI app with nested screens, dozens of user specific variables etc, e.g. imagine here an Outook 365 in the browser... and actually we are working on something just like that at work... you really want something close to a MVC structure rather than throwing variables from global function to global function.

Also I it seems that the url returned by the on_navigate-handler is not written to the url bar. See this small example where surfing to / shows the right content but the browser url does not change to /main:

Thanks for the remark, actually the history and URL management is executed after the URL has been selected. I will have a look at it once I am back in the office.

@ui.outlet('/', on_navigate=lambda _: '/main')
def layout():
    ui.label('main layout')
    yield

@layout.view('/main')
def main_content():
    ui.label('main content')

I also put this in a test called test_routing_url. And I think we need to write many more tests to ensure the code is working as expected.

Definitely.

@rodja
Copy link
Member

rodja commented Jun 23, 2024

Thanks for your explanations @Alyxion. It helped me to further work on the code. Besides more type fixes, I also did some renaming which (hopefully) conveys the purpose of variables/functions better.

I also got more insights and gathered some new questions. To help us keep track, here is a list of all my open points at the moment:

  1. I think we should merge ui.outlet and ui.view by allowing yield to be optional (see Experiment with allowing outlets without yield statement Alyxion/nicegui#3). The merged builder-decorator could be named ui.content or ui.view.
  2. While I would really like to also have a nice OOP way I think we should leave that to a follow-up pull request. The topic is already super complex.
  3. Could we merge SinglePageRouterConfig and Outlet? I moved setup_page to the Outlet class because it was only used there. And then found that this is also the case for build_page and build_page_template. The base class seems nowhere to be used by itself.
  4. I would love to reduce the complexity of the routing. Currently the Outlet itself resolves a target which is only checked for validity and then the router is asked to do the navigation (for which it resolves again). Maybe the router can do it all?
  5. Why do we need the router on an per-client-base? I think it would be more logical to have it in the page class. The only reason seems to be the user_data attribute which could be held in the Client directly or replaced with the existing app.storage.client concept.
  6. We should replace request_data injection in the page builder with Client.request from 2.0 branch (see provide client.request and make client.ip available even before client.connected (breaking change) #2399). If you agree I'll merge the change to 1.4 without the breaking change in the ip property.

@Alyxion
Copy link
Contributor Author

Alyxion commented Jun 24, 2024

Thanks for your explanations @Alyxion. It helped me to further work on the code. Besides more type fixes, I also did some renaming which (hopefully) conveys the purpose of variables/functions better.

I also got more insights and gathered some new questions. To help us keep track, here is a list of all my open points at the moment:

  1. I think we should merge ui.outlet and ui.view by allowing yield to be optional (see Experiment with allowing outlets without yield statement Alyxion/nicegui#3). The merged builder-decorator could be named ui.content or ui.view.

Sure, I will have a look next weekend.

  1. While I would really like to also have a nice OOP way I think we should leave that to a follow-up pull request. The topic is already super complex.

At least fully agree regarding the "super complex".

It were much appreciated though if after all refactoring of the next weeks and removing the official OOP implementation no all preparations were completely wiped and thus making it impossible for us to "attach" (as external component) a way to make it object oriented.

Especially the property SinglePageRouterConfig.router_class - which could of course be made protected - and the SinglePageRouter's resolve_target method. As we have already a project using them and I would like to avoid having to fork for another coupls of months it were helpful if had the chance to inofficially add it in the meantime without having to fork or using any dirty global function overwrites etc.

  1. Could we merge SinglePageRouterConfig and Outlet? I moved setup_page to the Outlet class because it was only used there. And then found that this is also the case for build_page and build_page_template. The base class seems nowhere to be used by itself.

Yes, that should be easily doable.

  1. I would love to reduce the complexity of the routing. Currently the Outlet itself resolves a target which is only checked for validity and then the router is asked to do the navigation (for which it resolves again). Maybe the router can do it all?

I get your point and I feel the same, will review it. The thing is here of course that the router by itself can not know about suboulets etc., at least not without e.g. copying some of the data to each new Router from the Outlet so it knows all valid paths.

  1. Why do we need the router on an per-client-base? I think it would be more logical to have it in the page class. The only reason seems to be the user_data attribute which could be held in the Client directly or replaced with the existing app.storage.client concept.

Actually we only need the "frame" per client. I just tried to separate UI and routing logic here.

Regarding the app.storage.client concept:

Yes and No. The (per level) current user_data gets automatically wiped if e.g. one outlet "stage" is left, e.g. in /a you add the menu bar, in /a/b you also add a footer and in /a/b/c an add. As they are currently stored in the SinglePageRouter they (e.g. the footer and add also get automatically wiped, once e.g. you navigate back to /a. As it's everything else than unlikely that such things also point to UI components here this implicitly ensures no links to old zombies are kept alive.

  1. We should replace request_data injection in the page builder with Client.request from 2.0 branch (see provide client.request and make client.ip available even before client.connected (breaking change) #2399). If you agree I'll merge the change to 1.4 without the breaking change in the ip property.

I struggled with that one back then but the FastAPI error was so cryptic that I could not figure out why I could not pass the request data directly into the page functions / API endpoints.

@Alyxion
Copy link
Contributor Author

Alyxion commented Jun 30, 2024

  • Made use of the Client.request and removed request_data.
  • Merged Outlet and SinglePageRouterConfig
    • As the SinglePageRouterPath was only used by the SinglePageRouterConfig I renamed it accordingly to OutletPath
  • Merged your pull request.
    • It can not be applied to index sites yet ('/'), so when I tried converting the .views to .outlet in the single_page_app_complex demo it did not work anymore b/c it of course assume it's standard behavior and then catches all pages with contain it's name. Will have a look next weekend again.

@nistvan86
Copy link

Hi!
I don't like to derail this thread too much. I've stumbled upon NiceGUI recently, and as part of evaluation I was checking if it could be used for SPA applications, and found this PR which seems to be in a far progressed state.
How much do you expect the API to change still? How usable is the source branch is according to your tests?
I'm only planning some toy project to be based on this, but would be to good to know still if I could start building on top of it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants