maxence-charriere / go-app

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

How to make wasm reactive? #536

Closed mar1n3r0 closed 2 years ago

mar1n3r0 commented 3 years ago

The way Go treats wasm as an application means it runs and exits. This is quite limiting compared to modern JS frameworks with their reactive APIs which allow real-time reactivity on external changes. Is this even possible with the current state of things? Maybe keeping dedicated channels open infinite? This would allow all kind of things from state management stores to webhooks to websockets etc.

Go takes a different approach, Go treats this as an application, meaning that you start a Go runtime, it runs, then exits and you can’t interact with it. This, coincidentally, is the error message that you’re seeing, our Go application has completed and cleaned up.

To me this feels more closer to the .NET Console Application than it does to a web application. A web application doesn’t really end, at least, not in the same manner as a process.

And this leads us to a problem, if we want to be able to call stuff, but the runtime want to shut down, what do we do?

https://www.aaron-powell.com/posts/2019-02-06-golang-wasm-3-interacting-with-js-from-go/

Here is an example:

I would like to connect the app to an external state management store and make it re-render on state changes. By the time the state changed the runtime has exited, hence unless there is a channel open to keep it alive it can't react to external events after exit.

mar1n3r0 commented 3 years ago

Hmmm seems like the approach is the same as the UI goroutine. Does that mean that if I create an external state management store package all it needs in order to be compatible is to make it a dependency that the framework opens channel x for communication in between ? Or could the store open this channel when imported to the framework and call the UI goroutine on state changes ?

Let's say we have this state managament store:

https://github.com/luisvinicius167/godux

What would it take for go-app to work with it as an alternative to the centralized HTTP requests and caching so that each component can set and get shared state from the store instead ?

The way I understand it so far the godux package needs to call Dispatch and Update on the UI goroutine everyime a state is updated. This seems to be an issue when it comes to compatibility with other frameworks since each of them would name the goroutine in different ways or maybe it can scan for running goroutines and find the rendering one by some criteria if there are more than one...

The ultimate goal I see from my usage so far is to have a fully automated re-rendering of single components selectively based on changed state. That is to say if a global state property is changed and components a,b and c depend on it they immediately re-render but not component d which does not depend on that property. And the hardest part is to get that right when multiple components update it async without centralized HTTP requests and multiple calls to get a cached version of the state.

maxence-charriere commented 3 years ago

There is no observable in Go and doing a mechanism like this results in a very heavy API that no one would enjoy using.

I had been able to nearly suppress the need for manual updates in v9 which really improve the usability of the package. => https://github.com/maxence-charriere/go-app/issues/519

This was made possible because I know now where things are updated. I also introduced a Mediator mechanism that allows components to subscribe to some messages. That can be used to propagate a state across multiple components without them having dependencies between each other.

type Context interface {
    context.Context

    // Setups the handler to listen to the given message.
    Handle(msg string, h MsgHandler)

    // Dispatches the given value to all the handlers that listen for the  given
    // message.
    Post(msg string, v interface{})

    // ...
}

// MsgHandler represents a handler to listen to messages sent with Context.Post.
type MsgHandler func(Context, interface{})

You could pass a context to another tool and use the Post function to propagate a given state.

mar1n3r0 commented 3 years ago

Thanks I was looking for the observable pattern indeed. I do appreciate all the efforts but I still believe that an external add-on store is the most elegant solution to that and allows observance. Plus it allows legacy versions to get it separately. Horizontal communication would easily become unmanageable at scale since it's a message broker pattern in essence which can also be an external add-on rather than being built-in. Including a message broker makes quite a vendor lock in as well. I believe at scale everything should be as modular as possible otherwise upgrades between versions for bigger projects are a dead end.

mar1n3r0 commented 3 years ago

It seems it is not only possible but pretty much standardized:

https://gobyexample.com/stateful-goroutines

Anytime a component wants to read data it sends the corresponding key to the read chan on the state goroutine. Anytime it wants to write data it sends key and val to the write chan on the state goroutine. In that case component structs are it's internal props only. I will try and make it a separate package and test with one of the demos.

It shouldn't require any changes to the API itself and can remain an optional separate package that can work with all other Go wasm frameworks too the same way thus making it a universal state store.

maxence-charriere commented 3 years ago

I was working on https://murlok.io and I finally get to a point where I needed state as you describe it.

After reading about redux and the godux implementation, I tried different approaches to provide something similar. It ended with more or less complicated things that I did not like.

Eventually, I realized that the redux/godux really seems like a mediator pattern where msg is called action, coupled with a GetState/SetState that is actually a concurrent safe key/value store.

I'm still experimenting but so far those are the requirements I see in order to decouple a state from components. Requirement Redux/Godux go-app
Propagate an action store.Dispatch(actionType Action)
godux.Action( Type string, Value interface{})
ctx.Post(action string, v interface{})
Handle an action store.Reducer(func(action godux.Action)) app.Handle(action string, func (ctx app.Context, v interface{})) - aynchronous
Notify the UI (component) store.Reducer(func(action godux.Action)) ? ctx.Handle(action string, func (ctx app.Context, v interface{})) - on UI goroutine
Set a state godux.SetState(name string, value interface{}) ctx.States().Set(name string, v interface{})
Get a state godux.GetState(name string) interface{} ctx.States().Get(name string) v interface{}

Why finally I'd like to implement something like this?

Note that I'm still experimenting with this and there are some problems I may not grasp yet. Thanks for debating about this, I feel it really helps to have a better API with this package.

mar1n3r0 commented 3 years ago

Thanks for taking the time to elaborate on the details. I also believe the mediator pattern is very useful for component communication. The store and the mediator are different non-related concepts though. Coming from a few years of vue experience and js ecosystem bloatware disappointment I am very frightened that the Go wasm ecosystem will become very opinionated and non-compatible with each other unless it is taken into consideration in it's early stages. This is why I consider mediators and state stores to be separate packages and most of all working with other projects like vugu and vecty at the same time. Including an opinionated solution to something in the core package makes it a vendor lock-in forever.

The store is way simpler than redux due to stateful routines. All it needs is open channels for read and write. I am working on figuring them out in a separate package so that the framework can just use them.

mar1n3r0 commented 3 years ago

@maxence-charriere Here is a very basic in-memory key value store based on stateful goroutines.

https://github.com/mar1n3r0/gostatestore

The scenario I am testing is the hello demo package, creating a second component and trying to get and set the same shared property. The issues I have so far is that if each component is a new tab they don't seem to share memory at all - are they 2 completely isolated instances ?

The other one which I am keen on trying is to have a single page composed of two components and observing updates between them. No idea how to do that yet with 2 separate structs passed for the same path and composing UIs.

I think something of use could be a hooks API per component which can be called from any external package to perform actions that are otherwise internal like render() otherwise I don't see how the static nature can be changed towards a state managed behavior.

maxence-charriere commented 3 years ago

are they 2 completely isolated instances?

Memory is not shared between browser tabs. You have to use things like local storage to do this kind of thing. This is browser behavior.

mar1n3r0 commented 3 years ago

are they 2 completely isolated instances?

Memory is not shared between browser tabs. You have to use things like local storage to do this kind of thing. This is browser behavior.

Forgot about it. This in theory means that... the only possible state store for Web Assembly is a key-value store that is running on a separate server with its own address. Something like memcached etc.

How about the SPA approach? Say we wanna render two components in the same single page app and at the same address. How do we go about rendering the two markups in one place ?

maxence-charriere commented 3 years ago

Forgot about it. This in theory means that... the only possible state store for Web Assembly is a key-value store that is running on a separate server with its own address.

You can implement a key-value store based on local storage or indexedDB. This is not a WebAssembly specificity.

How about the SPA approach? Say we wanna render two components in the same single page app and at the same address. How do we go about rendering the two markups in one place ?

This is impossible that they have the same memory address. You can have 2 components of the same type on your page but that will not be the same instance.

mar1n3r0 commented 3 years ago

https://github.com/AOHUA/redux-state-sync#readme

I thought something like this would be possible with Go channels.

maxence-charriere commented 3 years ago

You can probably do a store based on this. You will have to write it using https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel.

I just don't understand why you want to use channels.

you could do something like:

type Store interface{
    // Set the state and trigger updte of values registered with observe.
    Set(ctx context.Context, state string, v interface{})

    // Get a given state.
    Get(ctx context.Context, state string) interface{}

    // Would register the given value to be updated when a state is set. I would work like json.Unmarshal.
    Observe(ctx context.Context, state string, v interface{})
} 

You would have to implement the internal logic but there is no need for channel. Just sync.Mutex

maxence-charriere commented 3 years ago

I updated the previous msg

mar1n3r0 commented 3 years ago

It it because I mistakenly thought that go channels can be used on the web which was a wrong assumption and makes real-time state management impossible with Go only. Even if I implement broadcasting channels that would be basically using syscall/js.

The ultimate goal was to have global state available anywhere in real-time. So no matter which tab and component you are in you listen to a channel to get the current state.

maxence-charriere commented 3 years ago

Channels are usable on the Web. I use it for some stuff in go-app.

mar1n3r0 commented 3 years ago

Channels are usable on the Web. I use it for some stuff in go-app.

I had an in-depth look at the ui goroutine but noticed that it is working only until the wasm is compiled after that it's all web workers and no Go.

The current basic package gostatestore manages to maintain state within the component via channels but no way to pass it to other components.

maxence-charriere commented 3 years ago

I had an in-depth look at the ui goroutine but noticed that it is working only until the wasm is compiled after that it's all web workers and no Go.

This is incorrect. Besides the wasm loader and the pwa requirement, All is pure Go.

mar1n3r0 commented 3 years ago

You can probably do a store based on this. You will have to write it using https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel.

I just don't understand why you want to use channels.

you could do something like:

type Store interface{
    // Set the state and trigger updte of values registered with observe.
    Set(ctx context.Context, state string, v interface{})

    // Get a given state.
    Get(ctx context.Context, state string) interface{}

    // Would register the given value to be updated when a state is set. I would work like json.Unmarshal.
    Observe(ctx context.Context, state string, v interface{})
} 

You would have to implement the internal logic but there is no need for channel. Just sync.Mutex

Mutexes indeed are an alternative but given that we have channels I just thought it's worth to use it to our advantage.

See differences here:

https://gobyexample.com/mutexes

https://gobyexample.com/stateful-goroutines

In the previous example we used explicit locking with mutexes to synchronize access to shared state across multiple goroutines. Another option is to use the built-in synchronization features of goroutines and channels to achieve the same result. This channel-based approach aligns with Go’s ideas of sharing memory by communicating and having each piece of data owned by exactly 1 goroutine.

--

maxence-charriere commented 3 years ago

Mutexes indeed are an alternative but given that we have channels I just thought it's worth to use it to our advantage.

I recommend you this article: https://github.com/golang/go/wiki/MutexOrChannel

mar1n3r0 commented 3 years ago

Unfortunately both approaches don't solve the issue that Go channels are not web channels and the browser does not understand them as BroadcastChannel type.

mar1n3r0 commented 3 years ago

https://developer.mozilla.org/en-US/docs/Web/API/Broadcast_Channel_API

I think this could work. Still experimenting with how to pass a callback to event listener but it looks like the API is not dispatching.

When a message is posted, a message event is dispatched to each BroadcastChannel object connected to this channel. A function can be run for this event with the onmessage event handler:

func (h *hello) OnMount() {
    bc := js.Global().Get("BroadcastChannel").New("test1")
    bc.Call("postMessage", "This is a test message.")
    go func() {
        bc.Set("onmessage", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
            message := args[0].String()
            fmt.Println("message received ")
            fmt.Println(message)
            return message
        }))
        app.Dispatch(func() {
            h.Update()
        })
    }()
}

https://www.digitalocean.com/community/tutorials/js-broadcastchannel-api

Things You Can Do with Broadcast Channels There are many things we can imagine. The most obvious use case is to share states. For example, if you use something like Flux or Redux to manage your state, you can broadcast a message so that your store remains the same across tabs. We can also imagine building something similar for state machines.

mar1n3r0 commented 3 years ago

That actually worked in the end.

func (h *hello) OnMount() {
    bc := js.Global().Get("BroadcastChannel").New("test1")
    bc.Call("postMessage", "This is a test message.")
}
func (h *welcome) OnMount() {
    bc := js.Global().Get("BroadcastChannel").New("test1")
    bc.Set("onmessage", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
        message := args[0].Get("data").String()
        fmt.Println("message received ")
        fmt.Println(message)
        return message
    }))
}

Screenshot_20210525_090943

mar1n3r0 commented 3 years ago

And here is a full demo of cross-component reactivity and cross-tabs reactivity using BroadcastChannel API only. No need for components passing references to each other.

https://user-images.githubusercontent.com/11375673/119454733-85c90600-bd41-11eb-8c2b-ac5a34a82c0c.mp4

https://user-images.githubusercontent.com/11375673/119454743-882b6000-bd41-11eb-8fa5-640ea63575f1.mp4

hello.go

package main

import (
    "fmt"
    "syscall/js"

    "github.com/maxence-charriere/go-app/v6/pkg/app"
)

type hello struct {
    app.Compo
    name string
    Name string
    bc   js.Value
}

func (h *hello) OnMount() {
    h.bc = js.Global().Get("BroadcastChannel").New("test1")
    h.bc.Set("onmessage", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
        message := args[0].Get("data").String()
        fmt.Println("message received ")
        fmt.Println(message)
        h.Name = message
        h.Update()
        return message
    }))
}

func (h *hello) Render() app.UI {
    return app.Div().Body(
        app.Main().Body(
            app.H1().Body(
                app.Text("Hello, "),
                app.If(h.Name != "",
                    app.Text(h.Name),
                ).Else(
                    app.Text("World"),
                ),
            ),
            app.Input().
                Value(h.name).
                Placeholder("What is your name?").
                AutoFocus(true).
                OnChange(h.OnInputChange),
            app.A().
                Class("app-button section").
                Href("welcome").
                Body(
                    app.Text("Welcome"),
                ),
        ),
    )
}

func (h *hello) OnInputChange(src app.Value, e app.Event) {
    h.Name = src.Get("value").String()
    app.Dispatch(func() {
        h.bc.Call("postMessage", h.Name)
        h.Update()
    })
}
welcome.go

package main

import (
    "fmt"
    "syscall/js"

    "github.com/maxence-charriere/go-app/v6/pkg/app"
)

type welcome struct {
    app.Compo
    name string
    Name string
    bc   js.Value
}

func (h *welcome) OnMount() {
    h.bc = js.Global().Get("BroadcastChannel").New("test1")
    h.bc.Set("onmessage", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
        message := args[0].Get("data").String()
        fmt.Println("message received ")
        fmt.Println(message)
        h.Name = message
        h.Update()
        return message
    }))
}

func (h *welcome) Render() app.UI {
    return app.Div().Body(
        app.Main().Body(
            app.H1().Body(
                app.Text("Welcome, "),
                app.If(h.Name != "",
                    app.Text(h.Name),
                ).Else(
                    app.Text("World"),
                ),
            ),
            app.Input().
                Value(h.Name).
                Placeholder("What is your name?").
                AutoFocus(true).
                OnChange(h.OnInputChange),
            app.A().
                Class("app-button section").
                Href("/").
                Body(
                    app.Text("Hello"),
                ),
        ),
    )
}

func (h *welcome) OnInputChange(src app.Value, e app.Event) {
    h.Name = src.Get("value").String()
    app.Dispatch(func() {
        h.bc.Call("postMessage", h.Name)
        h.Update()
    })
}
maxence-charriere commented 3 years ago

cool 👍 .

Just a note:

func (h *welcome) OnInputChange(src app.Value, e app.Event) {
    h.Name = src.Get("value").String()
    app.Dispatch(func() {. // <-- here you don't need to call dispatch. go-app event handler are dispatched by default.
        h.bc.Call("postMessage", h.Name)
        h.Update()
    })
}
maxence-charriere commented 3 years ago

Hey @mar1n3r0, I'm currently working on a store implementation integrated into go-app. I found what you show me pretty interesting but since it is not supported on Safari, I'm still undecided about whether I should implement it.

What would be the use-cases where there is a need for a cross tab/window state?

c-nv-s commented 3 years ago

What would be the use-cases where there is a need for a cross tab/window state?

I think this would be a very useful feature... just my 2 cents

mar1n3r0 commented 3 years ago

Hey @mar1n3r0, I'm currently working on a store implementation integrated into go-app. I found what you show me pretty interesting but since it is not supported on Safari, I'm still undecided about whether I should implement it.

What would be the use-cases where there is a need for a cross tab/window state?

Given that it's widely supported among all other browsers it will land in Safari at some point. Besides the cases @c-nv-s mentioned for me this feature is a non-opinionated easy to implement global state.