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

javascript callbacks #876

Closed
g4bwy opened this issue Aug 22, 2023 · 14 comments
Closed

javascript callbacks #876

g4bwy opened this issue Aug 22, 2023 · 14 comments

Comments

@g4bwy
Copy link

g4bwy commented Aug 22, 2023

Hi,

I've been trying to pass Go functions as javascript callbacks to an external JS library, but I seem stuck on a weird issue.
(note: the sample below does not implement this exact use case but the results seem similar so I guess solving it might help me go further with the library issue)

given the following sample app based on the hello world sample:

package main

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

type jsfail struct {
        app.Compo
}

func (c *jsfail) Render() app.UI {
        return app.H1().Text("test")
}

func (c *jsfail) OnNav(ctx app.Context) {
        func1 := app.Window().Get("func1")
        func1.Invoke("directArg1", "directArg2")

        app.Window().Call("func2", func1)

        func2 := app.Window().Get("func2")
        func2.Invoke(func1)
}

func main() {
        app.Route("/", &jsfail{})
        app.RunWhenOnBrowser()

        h := &app.Handler{
                Name:        "Hello",
                Description: "An Hello World! example",
                RawHeaders: []string{
                        `<script>
                          window.func1 = function(arg1, arg2) {
                            console.log("FUNC1 " + arg1 + " " + arg2)
                          }

                          window.func2 = function(cb) {
                            cb("CbArg1", "CbArg2")
                          }
                         </script>
                         `,
                 },
        }

        http.Handle("/", h)

        if err := http.ListenAndServe("0.0.0.0:8010", nil); err != nil {
                log.Fatal(err)
        }
}

the first two calls (direct func1.Invoke and func2 call through app.Window().Call()) work fine, but the last one (direct func2 Invoke with func1 as callback parameter) fails with the following backtrace:

FUNC1 directArg1 directArg2
FUNC1 CbArg1 CbArg2
panic: ValueOf: invalid value [recovered]
 	panic: ValueOf: invalid value
 
goroutine 1 [running]:
github.com/maxence-charriere/go-app/v9/pkg/app.RunWhenOnBrowser.func1()
 	/home/gab/go/pkg/mod/github.com/maxence-charriere/go-app/v9@v9.8.0/pkg/app/app.go:109 +0x4
panic({0x4fe80, 0x16a730})
 	/usr/lib/go-1.21/src/runtime/panic.go:920 +0x36
syscall/js.ValueOf({0xcc460, 0x181c620})
 	/usr/lib/go-1.21/src/syscall/js/js.go:209 +0xf6
syscall/js.makeArgs({0x181c690, 0x1, 0x1})
 	/usr/lib/go-1.21/src/syscall/js/js.go:361 +0x7
syscall/js.Value.Invoke({{}, 0x7ff8000400000030, 0x180c3b8}, {0x181c690, 0x1, 0x1})
 	/usr/lib/go-1.21/src/syscall/js/js.go:410 +0x2
github.com/maxence-charriere/go-app/v9/pkg/app.value.Invoke(...)
 	/home/gab/go/pkg/mod/github.com/maxence-charriere/go-app/v9@v9.8.0/pkg/app/js_wasm.go:52
main.(*jsfail).OnNav(0x183c240, {0x170dd8, 0x183c420})
 	/home/gab/ssh/yb/cwa/go-frontend/jsfail/main.go:25 +0x23
github.com/maxence-charriere/go-app/v9/pkg/app.Dispatch.do({0x0, {0x17470b8, 0x183c240}, 0x180a1f8})
 	/home/gab/go/pkg/mod/github.com/maxence-charriere/go-app/v9@v9.8.0/pkg/app/dispatcher.go:161 +0xc
github.com/maxence-charriere/go-app/v9/pkg/app.(*engine).handleDispatch(0x186e480, {0x0, {0x17470b8, 0x183c240}, 0x180a1f8})
 	/home/gab/go/pkg/mod/github.com/maxence-charriere/go-app/v9@v9.8.0/pkg/app/engine.go:362 +0x11
github.com/maxence-charriere/go-app/v9/pkg/app.RunWhenOnBrowser.(*engine).start.func9()
 	/home/gab/go/pkg/mod/github.com/maxence-charriere/go-app/v9@v9.8.0/pkg/app/engine.go:340 +0x1b
sync.(*Once).doSlow(0x186e4ec, 0x189dd58)
 	/usr/lib/go-1.21/src/sync/once.go:74 +0x9
sync.(*Once).Do(0x186e4ec, 0x189dd58)
 	/usr/lib/go-1.21/src/sync/once.go:65 +0x6
github.com/maxence-charriere/go-app/v9/pkg/app.(*engine).start(...)
 	/home/gab/go/pkg/mod/github.com/maxence-charriere/go-app/v9@v9.8.0/pkg/app/engine.go:321
github.com/maxence-charriere/go-app/v9/pkg/app.RunWhenOnBrowser()
 	/home/gab/go/pkg/mod/github.com/maxence-charriere/go-app/v9@v9.8.0/pkg/app/app.go:157 +0xa3
main.main()
 	/home/gab/ssh/yb/cwa/go-frontend/jsfail/main.go:30 +0x3

my interpretation of app.Value's documentation is that the window.Call() invocation should be equivalent to the func2.Invoke() one, but for some reason it isn't and the call to func2 fails while converting the params...

might I have missed some type conversion on func1 before passing it to func2.Invoke() ?

cheers,

@mlctrez
Copy link
Contributor

mlctrez commented Aug 22, 2023

This does seem like odd behavior.

In this PR: https://github.com/maxence-charriere/go-app/pull/446/files

cleanArgs() was added to Call and New:

func (v value) Call(m string, args ...any) Value {
	args = cleanArgs(args...)
	return val(v.Value.Call(m, args...))
}

func (v value) New(args ...any) Value {
	args = cleanArgs(args...)
	return val(v.Value.New(args...))
}

However it is not present in Invoke:

func (v value) Invoke(args ...any) Value {
	return val(v.Value.Invoke(args...))
}

Adding this locally seems to fix your issue and does not break any tests in pkg/app

func (v value) Invoke(args ...any) Value {
	args = cleanArgs(args...)
	return val(v.Value.Invoke(args...))
}

@maxence-charriere - Should cleanArgs() also be applied in the Invoke case?

@g4bwy
Copy link
Author

g4bwy commented Aug 23, 2023

Hi,

thanks for the tip, doing the same modification locally works for me.
however, I found another similar failure case (which actually matches the pattern used in the external JS library I need to use), a bit more complicated this time ;)

here's the modified test:

package main

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

type jstest struct {
        app.Compo
}

func (c *jstest) Render() app.UI {
        return app.H1().Text("test")
}

func test1() {
        func1 := app.Window().Get("func1")
        func1.Invoke("directArg1", "directArg2")

        app.Window().Call("func2", func1)

        func2 := app.Window().Get("func2")
        func2.Invoke(func1)
}

func test2() {
	goFunc := func(this app.Value, args []app.Value) any {
		app.Logf("goFunc")
		return nil
	}

	func3 := app.Window().Get("func3")

	func3.Invoke(map[string]interface{}{
		"callbacks": []any{
			map[string]interface{}{
				"run": app.FuncOf(goFunc),
			},
		},
	})
}

func (c *jstest) OnNav(ctx app.Context) {
	test1()
	test2()
}

func main() {
        app.Route("/", &jstest{})
        app.RunWhenOnBrowser()

        h := &app.Handler{
                Name:        "Test",
                Description: "Test JS interaction",
                RawHeaders: []string{
                        `<script>
                          window.func1 = function(arg1, arg2) {
                            console.log("FUNC1 " + arg1 + " " + arg2)
                          }

                          window.func2 = function(cb) {
                            cb("CbArg1", "CbArg2")
                          }

			  window.func3 = function(obj) {
			    obj.callbacks[0].run("Indirect1", "Indirect2")
			  }
                         </script>
                         `,
                 },
        }

        http.Handle("/", h)

        if err := http.ListenAndServe("0.0.0.0:8010", nil); err != nil {
                log.Fatal(err)
        }
}

I'm getting another panic in this case:

ValueOf: invalid value [recovered]
	panic: ValueOf: invalid value

goroutine 1 [running]:
github.com/maxence-charriere/go-app/v9/pkg/app.RunWhenOnBrowser.func1()
	/home/gab/go/pkg/mod/github.com/maxence-charriere/go-app/v9@v9.8.0/pkg/app/app.go:109 +0x4
panic({0x4ff00, 0x169cb8})
	/usr/lib/go-1.21/src/runtime/panic.go:920 +0x36
syscall/js.ValueOf({0xcd940, 0x1836e40})
	/usr/lib/go-1.21/src/syscall/js/js.go:209 +0xf6
syscall/js.Value.Set({{}, 0x7ff8000100000037, 0x180c460}, {0xdaa56, 0x3}, {0xcd940, 0x1836e40})
	/usr/lib/go-1.21/src/syscall/js/js.go:306 +0x8
syscall/js.ValueOf({0x6d920, 0x1836e10})
	/usr/lib/go-1.21/src/syscall/js/js.go:205 +0xfa
syscall/js.Value.SetIndex({{}, 0x7ff8000100000036, 0x180c458}, 0x0, {0x6d920, 0x1836e10})
	/usr/lib/go-1.21/src/syscall/js/js.go:348 +0x8
syscall/js.ValueOf({0x4cf60, 0x180a210})
	/usr/lib/go-1.21/src/syscall/js/js.go:199 +0xff
syscall/js.Value.Set({{}, 0x7ff8000100000035, 0x180c448}, {0xdc396, 0x9}, {0x4cf60, 0x180a210})
	/usr/lib/go-1.21/src/syscall/js/js.go:306 +0x8
syscall/js.ValueOf({0x6d920, 0x1836e70})
	/usr/lib/go-1.21/src/syscall/js/js.go:205 +0xfa
syscall/js.makeArgs({0x181c7c0, 0x1, 0x1})
	/usr/lib/go-1.21/src/syscall/js/js.go:361 +0x7
syscall/js.Value.Invoke({{}, 0x7ff8000400000033, 0x180c408}, {0x181c7c0, 0x1, 0x1})
	/usr/lib/go-1.21/src/syscall/js/js.go:410 +0x2
github.com/maxence-charriere/go-app/v9/pkg/app.value.Invoke({{{}, 0x7ff8000400000033, 0x180c408}}, {0x181c7c0, 0x1, 0x1})
	/home/gab/go/pkg/mod/github.com/maxence-charriere/go-app/v9@v9.8.0/pkg/app/js_wasm.go:54 +0x11
main.test2()
	/home/gab/ssh/yb/cwa/go-frontend/jsfail/main.go:33 +0x23
main.(*jstest).OnNav(0x183c240, {0x170ef8, 0x183c420})
	/home/gab/ssh/yb/cwa/go-frontend/jsfail/main.go:44 +0x2
github.com/maxence-charriere/go-app/v9/pkg/app.Dispatch.do({0x0, {0x17470b8, 0x183c240}, 0x180a1f8})
	/home/gab/go/pkg/mod/github.com/maxence-charriere/go-app/v9@v9.8.0/pkg/app/dispatcher.go:161 +0xc
github.com/maxence-charriere/go-app/v9/pkg/app.(*engine).handleDispatch(0x186e480, {0x0, {0x17470b8, 0x183c240}, 0x180a1f8})
	/home/gab/go/pkg/mod/github.com/maxence-charriere/go-app/v9@v9.8.0/pkg/app/engine.go:362 +0x11
github.com/maxence-charriere/go-app/v9/pkg/app.RunWhenOnBrowser.(*engine).start.func9()
	/home/gab/go/pkg/mod/github.com/maxence-charriere/go-app/v9@v9.8.0/pkg/app/engine.go:340 +0x1b
sync.(*Once).doSlow(0x186e4ec, 0x189dd58)
	/usr/lib/go-1.21/src/sync/once.go:74 +0x9
sync.(*Once).Do(0x186e4ec, 0x189dd58)
	/usr/lib/go-1.21/src/sync/once.go:65 +0x6
github.com/maxence-charriere/go-app/v9/pkg/app.(*engine).start(...)
	/home/gab/go/pkg/mod/github.com/maxence-charriere/go-app/v9@v9.8.0/pkg/app/engine.go:321
github.com/maxence-charriere/go-app/v9/pkg/app.RunWhenOnBrowser()
	/home/gab/go/pkg/mod/github.com/maxence-charriere/go-app/v9@v9.8.0/pkg/app/app.go:157 +0xa3
main.main()
	/home/gab/ssh/yb/cwa/go-frontend/jsfail/main.go:49 +0x3
exit code: 2

OTOH, if I wrap goFunc using this function instead of app.FuncOf, the parameters recursion seems to work:

// +build wasm,js

package main

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

func myFuncOf(fn func(this app.Value, args []app.Value) any) js.Func {
        return js.FuncOf(func(this js.Value, args []js.Value) any {
                jArgs := make([]app.Value, len(args))
                for i := range args {
                        jArgs[i] = app.ValueOf(args[i])
                }
                return fn(app.ValueOf(this), jArgs)
        })
}

My understanding is that go-app is wrapping the syscall/js interfaces to dodge the build constraints on import "syscall/js" when building server-side (is that right ?), but apparently covering all corner cases is a bit tricky due to syscall/js enforcing use of its own low-level types for conversion instead of compatible kinds (cf. golang/go#42188)

@g4bwy
Copy link
Author

g4bwy commented Aug 23, 2023

and now for something even more complicated, promises! 😨

package main

import (
        "log"
        "net/http"

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

type jstest struct {
        app.Compo
}

func (c *jstest) Render() app.UI {
        return app.H1().Text("test")
}

func test1() {
        func1 := app.Window().Get("func1")
        func1.Invoke("directArg1", "directArg2")

        app.Window().Call("func2", func1)

        func2 := app.Window().Get("func2")
        func2.Invoke(func1)
}

func test2(fail bool) {
	genPromise := func(this app.Value, args[]app.Value) any {
		goFunc := func(this app.Value, args []app.Value) any {
			resolve := args[0]
			reject := args[1]

			go func() {
				if !fail {
					resolve.Invoke("hi from goFunc")
				} else {
					reject.Invoke("goFunc failed")
				}
			}()
			return nil
		}

		return app.Window().Get("Promise").New(app.FuncOf(goFunc))
	}

	func3 := app.Window().Get("func3")
	func3.Invoke(map[string]interface{}{
		"callbacks": []any{
			map[string]interface{}{
				"run": myFuncOf(genPromise),
			},
		},
	})
}

func (c *jstest) OnNav(ctx app.Context) {
	test1()
	test2(false)
	test2(true)
}

func main() {
        app.Route("/", &jstest{})
        app.RunWhenOnBrowser()

        h := &app.Handler{
                Name:        "Test",
                Description: "Test JS interaction",
                RawHeaders: []string{
                        `<script>
                          window.func1 = function(arg1, arg2) {
                            console.log("FUNC1 " + arg1 + " " + arg2)
                          }

                          window.func2 = function(cb) {
                            cb("CbArg1", "CbArg2")
                          }

			  window.func3 = function(obj) {
			    obj.callbacks[0].run()
			      .then((msg) => console.log(msg))
		              .catch((err) => console.log("FAIL " + err))
			  }
                         </script>
                         `,
                 },
        }

        http.Handle("/", h)

        if err := http.ListenAndServe("0.0.0.0:8010", nil); err != nil {
                log.Fatal(err)
        }
}

panic

ValueOf: invalid value [recovered]
	panic: ValueOf: invalid value

goroutine 1 [running]:
github.com/maxence-charriere/go-app/v9/pkg/app.RunWhenOnBrowser.func1()
	/home/gab/go/pkg/mod/github.com/maxence-charriere/go-app/v9@v9.8.0/pkg/app/app.go:109 +0x4
panic({0x4ff60, 0x16a018})
	/usr/lib/go-1.21/src/runtime/panic.go:920 +0x36
syscall/js.ValueOf({0xcc700, 0x181c880})
	/usr/lib/go-1.21/src/syscall/js/js.go:209 +0xf6
syscall/js.Value.Set({{}, 0x7ff8000100000036, 0x180c418}, {0xdb61e, 0x6}, {0xcc700, 0x181c880})
	/usr/lib/go-1.21/src/syscall/js/js.go:306 +0x8
syscall/js.handleEvent()
	/usr/lib/go-1.21/src/syscall/js/func.go:103 +0x26
syscall/js.Value.Invoke({{}, 0x7ff8000400000031, 0x180c3c8}, {0x181c720, 0x1, 0x1})
	/usr/lib/go-1.21/src/syscall/js/js.go:411 +0x3
github.com/maxence-charriere/go-app/v9/pkg/app.value.Invoke({{{}, 0x7ff8000400000031, 0x180c3c8}}, {0x181c720, 0x1, 0x1})
	/home/gab/go/pkg/mod/github.com/maxence-charriere/go-app/v9@v9.8.0/pkg/app/js_wasm.go:53 +0xb
main.test2(0x0)
	/home/gab/ssh/yb/cwa/go-frontend/jsfail/main.go:48 +0x26
main.(*jstest).OnNav(0x183c240, {0x171258, 0x183c420})
	/home/gab/ssh/yb/cwa/go-frontend/jsfail/main.go:59 +0x3
github.com/maxence-charriere/go-app/v9/pkg/app.Dispatch.do({0x0, {0x17470b8, 0x183c240}, 0x180a1f8})
	/home/gab/go/pkg/mod/github.com/maxence-charriere/go-app/v9@v9.8.0/pkg/app/dispatcher.go:161 +0xc
github.com/maxence-charriere/go-app/v9/pkg/app.(*engine).handleDispatch(0x186e480, {0x0, {0x17470b8, 0x183c240}, 0x180a1f8})
	/home/gab/go/pkg/mod/github.com/maxence-charriere/go-app/v9@v9.8.0/pkg/app/engine.go:362 +0x11
github.com/maxence-charriere/go-app/v9/pkg/app.RunWhenOnBrowser.(*engine).start.func9()
	/home/gab/go/pkg/mod/github.com/maxence-charriere/go-app/v9@v9.8.0/pkg/app/engine.go:340 +0x1b
sync.(*Once).doSlow(0x186e4ec, 0x189dd58)
	/usr/lib/go-1.21/src/sync/once.go:74 +0x9
sync.(*Once).Do(0x186e4ec, 0x189dd58)
	/usr/lib/go-1.21/src/sync/once.go:65 +0x6
github.com/maxence-charriere/go-app/v9/pkg/app.(*engine).start(...)
	/home/gab/go/pkg/mod/github.com/maxence-charriere/go-app/v9@v9.8.0/pkg/app/engine.go:321
github.com/maxence-charriere/go-app/v9/pkg/app.RunWhenOnBrowser()
	/home/gab/go/pkg/mod/github.com/maxence-charriere/go-app/v9@v9.8.0/pkg/app/app.go:157 +0xa3
main.main()
	/home/gab/ssh/yb/cwa/go-frontend/jsfail/main.go:65 +0x3

a bit like the previous case, a workaround is to call syscall/js directly from a build-constrained wrapper function.

instead of returning app.Window().Get("Promise").New(app.FuncOf(goFunc)), calling this function on goFunc passes the test:

func myPromise(fn func(this app.Value, args[]app.Value) any) js.Value {
        return js.Global().Get("Promise").New(myFuncOf(fn))
}

@mlctrez
Copy link
Contributor

mlctrez commented Aug 23, 2023

My understanding is that go-app is wrapping the syscall/js interfaces to dodge the build constraints on import "syscall/js" when building server-side ....

That's my understanding as well. Components are used both for pre-rendered pages on the server and for manipulation of the dom in the browser. There are also some additional go-app hooks that make sure js functions are correctly released during the component lifecycle.

Regarding the other two cases you found: There are probably more places where the conversion between app.Value and js.Value are not quite 100% correct passing between those two packages.

I'll look around a bit on your examples to see if there is another spot in the code that could be bundled in with the change to Invoke() that fixed your original question.

@g4bwy
Copy link
Author

g4bwy commented Aug 23, 2023

well, for the indirect call case, I guessed there could be an issue with the switch doing type recursion in cleanArg(), but at first glance I couldn't see any problem just by reading the code (I tried good-ol' printf debugging in this function, but app.Log triggers an infinite call recursion ;p), so I'm a bit confused there...

I guess the Promise issue is most probably a consequence of the recursion issue, since the whole call stack goes through syscall/js and not go-app's wrapper (I checked the same promise code without the indirect call and it works without any workaround)

@mlctrez
Copy link
Contributor

mlctrez commented Aug 23, 2023

Also, I forgot to mention this for working with promises - don't now if that will help or hurt your case.

I'm still narrowing in on where the type conversion fails.

@g4bwy
Copy link
Author

g4bwy commented Aug 23, 2023

Also, I forgot to mention this for working with promises - don't now if that will help or hurt your case.

in my use case, then() is called by the external JS lib, so it doesn't actually matter, but I'll keep that info for whenever I need it, thanks ;)

@mlctrez
Copy link
Contributor

mlctrez commented Aug 23, 2023

Tracked it down 🧐, hopefully.

In addition to adding the cleanArgs call in the Invoke() method, these changes appear to fix the panic.
I tested only this code.

Original

go-app/pkg/app/js_wasm.go

Lines 367 to 375 in 1a6a8c2

case []any:
s := make([]any, len(v))
for i, val := range v {
s[i] = cleanArgs(val)
}
case Wrapper:
return jsval(v.JSValue())
}

Updated

	case []any:
		s := make([]any, len(v))
		for i, val := range v {
			s[i] = cleanArg(val)  // <- Args to Arg !!!
		}
		return s  // missing return

	case function:   // prevent passing app.function to syscall/js
		return v.fn

	case Wrapper:
		return jsval(v.JSValue())
	}

@g4bwy
Copy link
Author

g4bwy commented Aug 23, 2023

Tracked it down 🧐, hopefully.

In addition to adding the cleanArgs call in the Invoke() method, these changes appear to fix the panic. I tested only this code.

alright, fixes it for me (including the promise use case), thanks!

@mlctrez
Copy link
Contributor

mlctrez commented Aug 23, 2023

Hmm. I could not get the promise use case to work.. Did you change anything? Or are you still using your custom js only code?

@g4bwy
Copy link
Author

g4bwy commented Aug 23, 2023

sorry I think I talked too fast, probably forgot to flush the cache while reloading, let met retest everything

EDIT: after re-testing, the promise-less case works, but the promise still needs to be wrapped using myPromise() (which must still wrap the handler using myFuncOf instead of app.FuncOf because it is passed to syscall/js's Value.New())

@mlctrez
Copy link
Contributor

mlctrez commented Aug 23, 2023

OK. I think there still might be a way to fix this case as well.. I'll follow up if I find anything.

@mlctrez
Copy link
Contributor

mlctrez commented Aug 23, 2023

I thought this conversion challenge looked a bit familiar.
@maxence-charriere please comment if this PR is appropriate.

Previous issue: #359 and the resulting PR

The challenge is that the PR only added the method to js_wasm.go and not js_nowasm.go. It can be made to work with additional files and build constraints, but a cleaner solution would be to change the signature to return any and add a no-op implementation to js_nowasm.go.

js_wasm.go

// JSValue returns the underlying syscall/js value of the given Javascript value.
func JSValue(v Value) any {
	return jsval(v)
}

js_nowasm.go

// JSValue returns the underlying syscall/js value of the given Javascript value.
// For non-wasm paths this just returns v.
func JSValue(v Value) any {
	return v
}

Then your promise code would work as-is. note the last return line

	genPromise := func(this app.Value, args []app.Value) any {
		goFunc := func(this app.Value, args []app.Value) any {
			resolve := args[0]
			reject := args[1]

			go func() {
				if !fail {
					resolve.Invoke("hi from goFunc")
				} else {
					reject.Invoke("goFunc failed")
				}
			}()
			return nil
		}

		return app.JSValue(app.Window().Get("Promise").New(app.FuncOf(goFunc)))
	}

@g4bwy
Copy link
Author

g4bwy commented Aug 24, 2023

I just tested with this PR and everything works fine now, thanks!

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

No branches or pull requests

3 participants