maxence-charriere / go-app

A package to build progressive web apps with Go programming language and WebAssembly.
https://go-app.dev
MIT License
7.95k stars 365 forks source link

Rigth approach to routing components hierarachy #730

Closed ppiccolo closed 5 months ago

ppiccolo commented 2 years ago

Hi @maxence-charriere, first of all many thanks for this brilliant piece of software.

Now I'm try to learn how to work with this, and I like to know if the following way can be considered the right way or not to instantiate a component based on the Path.

package main

import (
    "github.com/maxence-charriere/go-app/v9/pkg/app"
    "log"
    "net/http"
    "test1/components"
)

func main() {
    app.RouteWithRegexp("^/.*", &components.RootCompo{})

    app.RunWhenOnBrowser()

    http.Handle("/", &app.Handler{
        Name: "App",
        Styles: []string{
            "web/css/dashboard.css",
            "web/css/uikit.min.css",
        },
        Scripts: []string{
            "web/js/uikit.min.js",
            "web/js/uikit-icons.min.js",
        },
    })

    if err := http.ListenAndServe(":8000", nil); err != nil {
        log.Fatal(err)
    }
}
package components

import app "github.com/maxence-charriere/go-app/v9/pkg/app"

type ContentContainerCompo struct {
    app.Compo
    Content app.UI
}

func (c *ContentContainerCompo) Render() app.UI {
    return app.Div().
        ID("content").
        DataSet("uk-height-viewport", "expand: true").
        Body(
            app.Div().
                Class("uk-container uk-container-expand").
                Body(c.Content),
        )
}
package components

import (
    "fmt"
    "github.com/maxence-charriere/go-app/v9/pkg/app"
)

type RootCompo struct {
    app.Compo
    contentContainer app.UI
}

func (r *RootCompo) Render() app.UI {
    return app.
        Div().
        Body(
            &HeaderCompo{},
            &LeftBarCompo{},
            r.contentContainer,
            &OffCanvasCompo{},
        )
}

func (r *RootCompo) OnNav(ctx app.Context) {
    cc := &ContentContainerCompo{}

    if ctx.Page().URL().Path == "/settings" {
        cc.Content = &SettingsCompo{}
    }
    if ctx.Page().URL().Path == "/home" {
        cc.Content = &homeCompo{}
    }
    r.contentContainer = cc
}

thanks in advance

oderwat commented 2 years ago

I think @maxence-charriere's planned way to instantiate the components is through routing the individual paths by app.Route.... I kinda dislike that and had my own router with a wildcard similar to yours from the very beginning of using go-app. This approach did lead to (obscure) problems because for example, you can not replace a component with another component of the same type as it will not "replace" it, but update the former one.

I run into that problem as soon as I had something like a "markdown" component with many instances, which had different data to render for different pages as internal data. So when I switched it was not changing the component but updating the fields and so it was overwriting the data of the first mounted component and basically destroyed its internal state. I needed some time to figure out what happens. @maxence-charriere approach for a similar component is to store the data (translated markdown) at another place and rebuilds the components all the time.

This works, but in my eyes, the reason to have components is also to "reuse their internal state". If this is not possible you need to have state information at other places just to remember something like what tab was selected last in tab navigation for containers and such. But also larger states like the content of a form that you want to "still have" when you navigate away and back.

I presume that you may run into this problem with your approach as you may want to keep components in their internal state after dismounting them and mount them later again when you go to the same URL. Afair @maxence-charriere is not liking my approach and prefers the complete deconstruction and reconstruction of components. He mentioned that he had strange behavior otherwise but that this may have gone now. I am pretty confident that it works well, as I have quite large prototypes which are using my approach to component usage.

I also wanted to have a router (and sub-routiner) that is much more flexible and uses a changing sitemap with roles that create dynamic navigations automatically based on different users or functionality I never was using his router. I also replace the "content" with different page components having the same state but different internal states, So I had to find a way to make this work. This is why I developed my mountpoint package, which lets you switch the component in place by using a proxy for that. The example shows why this is needed and how I use it.

Your example does not "yet" contain something that shows what I am talking about, but I am quite sure that your approach will run into similar problems as I had to get it to work. My current framework also uses a base component (the "app control") which then instantiates a "template" (which is what your root compo has hardcoded) for each path, while keeping a "default template" for most of them that consists of header/navigation and the actual content. This "page content" then will be a composition of multiple components where each can be either fresh or being in the dismounted state, ready for getting mounted again.

There will probably be some other problems that arise with your approach and that is the handling of fragment navigation and scroll positions. I may be wrong but I believe that your "cc.Content" will not update the scroll positions of the div when switching between content. So when you have one of them being really long and use navigations that scroll to, you need to do it yourself. I had to implement fragment navigation and scroll position resets for such "content" type components myself.

P.S.: I just felt that I want to share some of my findings. Not sure if there is much value for you there :)

ppiccolo commented 2 years ago

Hi @oderwat, thank you for your detailed explanation, and yes i suppose that you have right when say that I'll encountering some problems in more complex scenario, where the internal state of the component matter, in my simple experiment my way work, but off course the real world case will be different.

I saw your package and look interesting, I'm also curious to hear the @maxence-charriere point of view to understand the philosophy behind this, because also for me this way to think look natural BUT, I don't have the entire picture for sure.

I also wanted to have a router (and sub-routiner) that is much more flexible and uses a changing sitemap with roles that create dynamic navigations automatically based on different users or functionality I never was using his router.

Yes this will be a huge improvement to this project that look very promising.

justinfx commented 2 years ago

I've also come to the same point where I have questions about the best approach to structuring multiple components. My first project now has a similar shape to this original example, where I have the "root" app with header and other extra components around the central content component that is means to be switched.
I felt similar confusion about the router and currently also wildcard match to just the root app. Then I do my routing logic in the root app nav which is means to update the context details and switch in the right central component. It's not clear to me if I should be creating the instances of my 3 content views once on app mount or if I am supposed to recreate them on each render. Right now I am seeing some funky order of operations where

So I get a blip with 2 renders of the component and I'm just feeling like I have structured things wrong.

maxence-charriere commented 2 years ago

The way it is intended to be is that a Route create an instance of the associated component:

/foo => Foo{} /bar => Bar{}

Now lets say you wanna share some logic like a layout. Nothing stop you do do something like:

type Page struct{
    app.Compo
}

func (f *Foo) Render() app.UI {
    return &Page{...}
}

func (b *Bar) Render() app.UI {
    return &Page{...}
}

You are free to decide if you want to let go-app router do the routing job or if you want to handle it yourself in your own component.

I personally use the Route one in my projects. Sometimes I use the same component type in multiple pages (https://murlok.io), but those pages have literally the same logic, with the only difference being some path variable.

Feels like handling the rooting in a root component is complicating things. Just think a path load a component

justinfx commented 2 years ago

Thanks for the clarification, @maxence-charriere. That info, combined with the implementation details from @oderwat about how a private instance is created from the type explains some of the behaviours I am seeing. So let me ask a followup question. In the case of trying to use only a single catch-all route for the main app component, what happens in the following case:

type App struct {
    app.Compo
    content MainContent
}

func (a *App) Render() app.UI {
    return app.Div().
        Body(
            &HeaderCompo{},
            &r.content,
        )   
}

If the App is the only route, and it uses a nested MainContent, when is the mount meant to happen for the MainContent? Is this inherently wrong and buggy? From what I see, it would result in a render of the nested component before it gets a mount event. Is it pointless to keep it as a member of App? Does the framework end up creating a new instance from the type on the first time it sees it and mounting that?

oderwat commented 2 years ago

The mount (creating the dom node) happens once when the Render is called for the first time. That is before calling OnMount (as this will get the context and refers to the JS Node) for this component, but after OnInit (that will not receive a context). It will stay mounted until the component type changes (or the page is changing or gets reloaded). After the first mount, it will only update the node and its children by creating new "rendered" nodes, which get compared to and update the existing nodes in the dom. This is why I made that mount point package mentioned above, as I also want to have a meaningful state in the mounted component, especially if they have the same type. To me, a "currently not visible component" may still exist, and I want to show (mount) it again with its last state (or destroy/init it if not).

I suggest you add logging in all of your functions (either using app.Log() and add the texts or my dbg package, which does some call evaluation automatically at the current package (looks like "pwa/ux/pages/touchtype.(*TouchType).initTAPSy"). This way, you can see what is called and in which order. That made things much clearer to me, at least.

P.S.: @maxence-charriere, I am not sure that I understand every detail of your child's behaviors :)

maxence-charriere commented 2 years ago

@oderwat that sounds correct.

jorgefuertes commented 1 year ago

Has anyone here developed a page layout system to call out on each path and change only the non-repeating components?

I'm studying how go-app works and can't figure out how to do it.

Is it better to call on each path all the components that make up the whole page?

Thanks in advance.

oderwat commented 1 year ago

Actually, this is what go-app itself does. You render everything again and again (but you may decide what to update in the model data for that) and go-app decides what needs to be updated.

jorgefuertes commented 1 year ago

Aha, so its ok to create a new navbar component on each page and render it? Sounds like repeating code, but its ok if that is the pattern. I'll go that way.

Thanks!