Skip to content

Introduce ui.sub_pages to allow implementing single page applications (SPAs) #4821

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

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

Conversation

rodja
Copy link
Member

@rodja rodja commented Jun 1, 2025

Motivation

We already have an example on how to hand-build a Singe Page Application (SPA). Then, @Alyxion worked out the feature-rich but super large pull request #2811 to integrate SPAs directly into NiceGUI. While working/reviewing the code I came up with this alternative pull request which allows the following SPA syntax:

@ui.page('/')
@ui.page('/{_:path}') # tells FastAPI/Starlette that all path's should land on our SPA
def index():
    ui.label('some text before main content')
    ui.sub_pages({'/': child, '/other': child2})

def child():
    ui.link('goto other', '/other')

def child2():
    ui.link('goto main', '/')

Sub pages can also be nested. But only one ui.sub_pages element should be used on each hierarchy level.
If a sub page needs to modify content from it's parent, the object is simply passed via lambda:

@ui.page('/')
@ui.page('/{_:path}')
def index():
    title = ui.label()
    ui.sub_pages({
        '/': lambda: child(title),
        '/other': lambda: child2(title)
    })

def child(title: ui.label):
    title.set_text('child title')
    ui.link('goto other', '/other')

def child2(title: ui.label):
    title.set_text('other title')
    ui.link('goto main', '/')

Same as with most of NiceGUI's callable definitions this also works for async functions:

@ui.page('/')
@ui.page('/{_:path}')
def index(_):
    title = ui.label()
    ui.sub_pages({'/': lambda: main_content(title)})

async def main_content(title: ui.label):
    title.set_text('main title')
    await asyncio.sleep(1)
    ui.label('after 1 sec')

Paths to sub pages can contain parameters:

@ui.page('/')
@ui.page('/{_:path}')
def index(_):
    ui.select(label='item', options=['a', 'b', 'c'], on_change=lambda e: ui.navigate.to(f'/{e.value}'))
    ui.sub_pages({
       '/': main_content,
       '/{item}': main_content
    })

def main_content(item: str = 'nothing'):
    ui.label(f'item: {item}')

Key differences compared to #2811 (@Alyxion's SPA PR)

  • much less code (but obviously also only a fraction of the features)
  • central composition root architecture in favour over the more modular "Inversion of Control" IoC approach
  • self-explanatory shared state between pages via parameter passing (not yield with a new kind of object storage)
  • just a new UI element ui.sub_pages instead of decorators, routers etc.
  • this PR fully relies on FastAPI/Starlette for middlewares, routing etc; which sub page should be rendered is just an after-thought of the ui.sub_pages implementation (which looks at the url path to determine which content builder needs to be picked)
  • for custom routing with sub pages, users should derive from ui.sub_pages and override 'show()')

Implementation

I tried to create a PR as small as possible. But adding nested sub pages, async support and path parameters made it a lot more complicated than I originally thought. It is also still far from done.

If you want to dive into this, I recommend looking at test_sub_pages.py for examples on how the ui.sub_pages api works.

Progress

  • I chose a meaningful title that completes the sentence: "If applied, this PR will..."
  • Pytests have been added.
  • The implementation is complete.
    • make ui.navigate.to work with sub pages
    • allow default parameters for sub page functions
    • fix tests
    • simplify implementation
  • Documentation has been added.
    • add demo where the footer (below the ui.sub_page) is modified by the sub pages

@rodja rodja added feature Type/scope: New feature or enhancement in progress Status: Someone is working on it 🟡 medium Priority: Relevant, but not essential 🌳 advanced Difficulty: Requires deep knowledge of the topic labels Jun 1, 2025
@rodja
Copy link
Member Author

rodja commented Jun 1, 2025

@Alyxion and @evnchn what do you think about this approach? I already talked with @falkoschindler last week, but I would much appreciate your feedback. Where do you see problems or inherent limitations? Especially compared to #2811.

@evnchn
Copy link
Collaborator

evnchn commented Jun 1, 2025

@rodja Thanks for inviting me on to this journey.

Overall, this PR, #2811 (the original SPA), #4552 (Project ACID) and #4719 (Project BASE) should be viewed together, as at the end of the day, they achieve building a single-page application, hereby defined as a page which does NOT reload but loads-in new content nonetheless. pushstate and popstate, I consider it a cherry-on-top.

Parallels between Project ACID and this PR - re-implmementing the HTTP layer

Why I am saying this, is because I think @rodja you mentioned that you have to implement "async support and path parameters", which sounds very similar to back in #4552 (project ACID), where I re-implemented client initialization with async support (but left out path parameters since I didn't got around to do it) when the ACID maneuver occurs, and we'd like to spin up a client to match the browser.

In the end, we came to the conclusion that we'd be re-inventing the HTTP layer and all the middleware / path parameters / pydantic classes that FastAPI offers, and we did not pursue.

So, high-level speaking, although this PR is obviously way less cursed than #4552 (project ACID), I fear that there will equally be a high maintenance burden in "async support and path parameters"

Leveraging the existing HTTP layer with Project BASE

Then, if I am not mistaken, that in #4719, since we are making a HTTP request to the server for the latest content, that we do not need to re-implement "async support and path parameters", since we are leveraging the original functionality in @ui.page.

That PR has its own problems, but high-level speaking, the maintenance burden may be lower.

Can we move forward with the Original SPA PR, with this new mindset in mind?

Seems like this PR was created to supersede the original SPA PR, but given the above concerns over re-implementing the HTTP layer and maintenance burden, it may or may not be a good idea.

Though the original SPA PR is also very long and bulky, but at least we see, nicegui/content.py using @ui.page under the hood. This means that it could be using the existing page definition infrastructure, and if we rework that PR to be simplier, there may be a way out as well.

@rodja I'd recommend to check out #4719 for a fresh perspective on how this SPA problem may be solved, now that a bug has been resolved in that PR, which made it work in the entire NiceGUI documentation (though tests are failing for some version for some reason)

evnchn

This comment was marked as resolved.

@Alyxion
Copy link
Contributor

Alyxion commented Jun 1, 2025

First of all thank you very much helping to drive the implementation forward @rodja.

As already discussed in the video call I think yielding data can easily be replaced by storing in within the client storage for the most part - if calls are deeply nested - or by passing it directly to the sub pages as in your example. As the client storage is not context aware this requires manual cleanup in some scenarios though where as the yield apporach did this automatically when a sub page was left which yielded objects.

Async is cruicial in my opinion, as NiceGUI is basically a single-threaded app no serious apps with mulitple users could be realized without page and the sub page being declarable as async.

The samples above didn't contain code behind the sub page declaration, but I assume thats as well possible as code before?

Regarding the general concept at least for us the actual approach with outlet and outlet.view allowed far cleaner structuring of larger apps in my opinion. It behaved like an ApiRouter in FastAPI or a Blueprint in Flask where you could define the outlet in a shared, central module file and then all "plugins" could import the outlet and register themselves to it... like in an APIRouter.

With this approach now thats not possibl anymore. At the point of implementation the main page would already need to know all children and/or do some workaround everytime, e.g. like a registry where all sub pages had to enlist themselves so that the main page could then register them. Don't like it to be honest. Preferred the "router style".

What I like though is that you used the same pattern for the declaration of sub pages as FastAPI, e.g. {item}. Using wildcards here to catch everything wasn't the right approach as it would not allow the declaration of endpoints starting with the same URL dynamically - such as when using an ui.upload.

@rodja
Copy link
Member Author

rodja commented Jun 2, 2025

Response to @evnchn:

Overall, this PR, #2811 (the original SPA), #4552 (Project ACID) and #4719 (Project BASE) should be viewed together

Exactly.

pushstate and popstate, I consider it a cherry-on-top.

No. In my opinion, the url-path-handling is crucial to SPAs. Especially in NiceGUI where we offer a "backend-first" approach and handle the web-development details internally.

[in project ACID] we came to the conclusion that we'd be re-inventing the HTTP layer and all the middleware / path parameters / pydantic classes that FastAPI offers,

This PR's approach utilizes ui.page for http routing, middlewares, pydantic, etc. when accessing a page with sub pages through their full url. ui.sub_pages also does not reimplement routing as it is done in the original SPA PR #2811. Instead this PR just implements a minimalistic path-matching which is executed after FastAPI/Starlette does all it's magic. I'll update the description above.

[in project BASE], the maintenance burden may be lower.

True. I really like the concept and want to see project BASE (#4719) merged. Its remarkable elegant, small and brings a great user improvement. But it is not solution for SPAs in my opinion. In an SPA you want to only load once (in this its similar to BASE), provide browser navigation (eg. pushstate/popstate) and most importantly only update the changed content via efficient backend communication.

Though the original SPA PR #2811 is also very long and bulky, but at least we see, nicegui/content.py using @ui.page under the hood. This means that it could be using the existing page definition infrastructure, and if we rework that PR to be simplier, there may be a way out as well.

I tried. Which led me to this PR which avoids the reimplementation of full-fledged routing.


Response to @Alyxion:

Async is cruicial in my opinion, as NiceGUI is basically a single-threaded app no serious apps with mulitple users could be realized without page and the sub page being declarable as async.

Yes, ui.sub_pages allows the builder function to be async. I'll update the description of this PR.

Regarding the general concept at least for us the actual approach with outlet and outlet.view allowed far cleaner structuring of larger apps in my opinion. It behaved like an ApiRouter in FastAPI or a Blueprint in Flask where you could define the outlet in a shared, central module file and then all "plugins" could import the outlet and register themselves to it... like in an APIRouter.

True. Outlet, ApiRouter, Blueprint etc. are a lot more decentral by allowing self-registration. This "Inversion of Control" IoC is very modular and provides a loose coupling between main file and drop-in modules. But this startup-magic comes at the cost of hidden side-effects where an simple import mutates the global app state and it can be quite hard to understand the routing (because it is scattered through-out the project). ui.sub_pages on the other hand is implemented as a composition root where all routing is defined in one place (which also simplifies control over the registration order).

I'm leaning towards the central composition root architecture because it's simpler to use in small code bases. And -- as you said -- it can be extended to allow IoC by adding a registration mechanism for applications which require a more modular approach.

@evnchn
Copy link
Collaborator

evnchn commented Jun 2, 2025

Interesting. I think we can define a Single-Page Appication as follows:

  1. Only one browser load
  2. Capture browser's pushstate/popstate
  3. Load in only the updated content

This definition shall be useful across all PRs attempting to take a shot at the problem. Let us bear this in mind.

I have stuff to talk about how project BASE can achieve SPA, but I will put it at that PR's comment so avoid muddying waters.

So the way I understand it, in this PR we're dragging on one Client across SPA navigations, and with it state held in memory.

@ui.page('/')
@ui.page('/{_:path}') # tells FastAPI/Starlette that all path's should land on our SPA
def index():
    ui.label('some text before main content')
    # some state-dependent element
    ui.sub_pages({'/': child, '/other': child2, '/mutate': child3})

def child():
    ui.link('goto other', '/other')
    ui.link('goto mutate', '/mutate')

def child2():
    ui.link('goto main', '/')

def child3():
    # some stuff which modifies the state...
    ui.link('goto main', '/')

If I go from / to /mutate and back to /, I can get a different page result, despite being on /. I imagine this can get messy real quick. Do you agree? Or am I overthinking?

@rodja
Copy link
Member Author

rodja commented Jun 2, 2025

I do not quite understand your concern @evnchn. Can you provide an example with problematic code? Above you only placed comments which I do not know how to fill.

ui.sub_pages simply calls .clear() on the container before adding new content. In this regard it is very similar to ui.refreshable.

@evnchn evnchn mentioned this pull request Jun 2, 2025
7 tasks
@evnchn
Copy link
Collaborator

evnchn commented Jun 2, 2025

Depending on what you decide to do, the content in the main page can become wildly path-dependent. This could lead to confusion.

from nicegui import ui, app


@ui.page('/')
@ui.page('/{_:path}')
def index():
    app.storage.client['some_value'] = 0
    ui.label('some text before main content')
    ui.label().bind_text_from(app.storage.client, 'some_value')
    ui.sub_pages({'/': child, '/other': child2, '/mutate': child3})


def child():
    ui.link('goto other', '/other')
    ui.link('goto mutate', '/mutate')


def child2():
    ui.link('goto main', '/')


def child3():
    app.storage.client['some_value'] += 1
    ui.link('goto main', '/')


ui.run()

Initial load:

{8B260310-A423-4180-A597-06A38C5D51B6}

Clicking around in the page:

{AA37F2AD-1BAE-4412-9025-D6815FB08567}

@rodja rodja changed the title Introduce ui.sub_pages element to allow implementing single page applications (SPAs) Introduce ui.sub_pages to allow implementing single page applications (SPAs) Jun 5, 2025
@rodja
Copy link
Member Author

rodja commented Jun 7, 2025

@falkoschindler you can start with a first review if you find the time. I'll look into creating the documentation.

@rodja rodja requested a review from falkoschindler June 7, 2025 14:39
@rodja
Copy link
Member Author

rodja commented Jun 7, 2025

Oh, sorry @evnchn. I totally missed your comment

Depending on what you decide to do, the content in the main page can become wildly path-dependent. This could lead to confusion.

I do not see the problem. If you have pages which change a storage value -- of course they will alter the internal state. That behaviour is not specific to sub pages:

app.storage.general['some_value'] = 0

@ui.page('/')
def index():
    ui.label().bind_text_from(app.storage.general, 'some_value')
    ui.link('goto mutate', '/mutate')

@ui.page('/mutate')
def mutate():
    app.storage.general['some_value'] += 1
    ui.link('goto main', '/')

The main difference is that you can do such things with app.storage.client because it is a single page app (eg. single client app).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
🌳 advanced Difficulty: Requires deep knowledge of the topic feature Type/scope: New feature or enhancement in progress Status: Someone is working on it 🟡 medium Priority: Relevant, but not essential
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants