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

Add support for rendering into non-body DOM nodes #249

Merged
merged 4 commits into from Dec 1, 2019
Merged
Changes from all commits
Commits
File filter...
Filter file types
Jump to…
Jump to file or symbol
Failed to load files and symbols.

Always

Just for now

92 dom.go
@@ -1,6 +1,8 @@
package vecty

import "reflect"
import (
"reflect"
)

// batch renderer singleton
var batch = &batchRenderer{idx: make(map[Component]int)}
@@ -1155,45 +1157,102 @@ func requestAnimationFrame(callback func(float64)) int {
}

// RenderBody renders the given component as the document body. The given
// Component's Render method must return a "body" element.
// Component's Render method must return a "body" element or a panic will
// occur.
//
// This function blocks forever in order to prevent the program from exiting,
// which would prevent components from rerendering themselves in the future.
//
// It is a short-handed form for writing:
//
// err := vecty.RenderInto("body", body)
// if err !== nil {
// panic(err)
// }
// select{} // run Go forever
//
func RenderBody(body Component) {
target := global.Get("document").Call("querySelector", "body")
err := renderIntoNode("RenderBody", target, body)
if err != nil {
panic(err)
}
if !isTest {
select {} // run Go forever
}
}

// ElementMismatchError is returned when the element returned by a component
// does not match what is required for rendering.
type ElementMismatchError struct {
method, got, want string
}

func (e ElementMismatchError) Error() string {
return "vecty: " + e.method + `: expected Component.Render to return a "` + e.want + `", found "` + e.got + `"`
}

// InvalidTargetError is returned when the element targeted by a render is
// invalid because it is null or undefined.
type InvalidTargetError struct {
method string
}

func (e InvalidTargetError) Error() string {
return "vecty: " + e.method + `: invalid target element is null or undefined`
}

// RenderInto renders the given component into the existing HTML element found
// by the CSS selector (e.g. "#id", ".class-name") by replacing it.
//
// If there is more than one element found, the first is used. If no element is
// found, an error of type InvalidTargetError is returned.
//
// If the Component's Render method does not return an element of the same type,
// an error of type ElementMismatchError is returned.
func RenderInto(selector string, c Component) error {
target := global.Get("document").Call("querySelector", selector)
return renderIntoNode("RenderInto", target, c)
}

func renderIntoNode(methodName string, node jsObject, c Component) error {
if !node.Truthy() {
return InvalidTargetError{method: methodName}
}
// block batch until we're done
batch.scheduled = true
nextRender, skip, pendingMounts := renderComponent(body, nil)
nextRender, skip, pendingMounts := renderComponent(c, nil)
if skip {
panic("vecty: RenderBody Component.SkipRender illegally returned true")
panic("vecty: " + methodName + ": Component.SkipRender illegally returned true")
}
if nextRender.tag != "body" {
panic("vecty: RenderBody expected Component.Render to return a body tag, found \"" + nextRender.tag + "\"")
expectTag := node.Get("nodeName").String()

This comment has been minimized.

Copy link
@dmitshur

dmitshur Dec 1, 2019

Member

I believe I'm running into an issue with nodeName case sensitivity here (see https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeName and https://johnresig.com/blog/nodename-case-sensitivity/).

My program is doing:

// Initial frontend render.
err := vecty.RenderInto("body", body)
if err != nil {
	panic(fmt.Errorf("internal error: unexpected error from vecty.RenderInto: %v", err))
}

And getting:

Uncaught Error: internal error: unexpected error from vecty.RenderInto: vecty: RenderInto: expected Component.Render to return a "BODY", found "body"

I'm using HTML, Chrome 78.0.3904.108 on macOS.

This comment has been minimized.

Copy link
@dmitshur

dmitshur Dec 1, 2019

Member

I tried the following quick change to see if it would be sufficient to solve the problem:

diff --git a/dom.go b/dom.go
index 0eb000e..164a5a3 100644
--- a/dom.go
+++ b/dom.go
@@ -2,6 +2,7 @@ package vecty
 
 import (
 	"reflect"
+	"strings"
 )
 
 // batch renderer singleton
@@ -1225,7 +1226,7 @@ func renderIntoNode(methodName string, node jsObject, c Component) error {
 	if skip {
 		panic("vecty: " + methodName + ": Component.SkipRender illegally returned true")
 	}
-	expectTag := node.Get("nodeName").String()
+	expectTag := strings.ToLower(node.Get("nodeName").String())
 	if nextRender.tag != expectTag {
 		return ElementMismatchError{method: methodName, got: nextRender.tag, want: expectTag}
 	}

It was; after that, Go Package Store was rendering the frontend without issues.

I'm not sure if that's the optimal long term fix, or if there's a better way. You may not want to import a new strings package just for one function.

I tried using JavaScript's toLowerCase method, but for some reason that didn't work in the context of Vecty:

Uncaught Error: syscall/js: call of Value.Call on string

Even though it seems to work fine elsewhere, e.g., see https://gopherjs.github.io/playground/#/UxD9vDjbf0.

Hope this is helpful.

This comment has been minimized.

Copy link
@dmitshur

dmitshur Dec 1, 2019

Member

About the "syscall/js: call of Value.Call on string" error, I found it happens when using syscall/js package with GopherJS but not when using github.com/gopherjs/gopherjs/js package. So it's not Vecty-specific beyond Vecty using syscall/js.

It happens when using syscall/js with both GopherJS and Go/WebAssembly, so I filed golang/go#35917 to learn more about that.

This comment has been minimized.

Copy link
@slimsag

slimsag Dec 1, 2019

Author Member

Thank you for investigating this! I've sent #251 to fix the issue on the Vecty side

if nextRender.tag != expectTag {
return ElementMismatchError{method: methodName, got: nextRender.tag, want: expectTag}
}
doc := global.Get("document")
if doc.Get("readyState").String() == "loading" {
// avoid duplicate body
var cb jsFunc
cb = funcOf(func(this jsObject, args []jsObject) interface{} {
cb.Release()

doc.Set("body", nextRender.node)
replaceNode(nextRender.node, node)
mount(pendingMounts...)
if m, ok := body.(Mounter); ok {
if m, ok := c.(Mounter); ok {
mount(m)
}
requestAnimationFrame(batch.render)
return undefined
})
doc.Call("addEventListener", "DOMContentLoaded", cb)
return
return nil
}
doc.Set("body", nextRender.node)
replaceNode(nextRender.node, node)
mount(pendingMounts...)
if m, ok := body.(Mounter); ok {
if m, ok := c.(Mounter); ok {
mount(m)
}
requestAnimationFrame(batch.render)
if !isTest {
// run Go forever
select {}
}
return nil
}

// SetTitle sets the title of the document.
@@ -1219,6 +1278,7 @@ type jsObject interface {
Delete(key string)
Call(name string, args ...interface{}) jsObject
String() string
Truthy() bool
Bool() bool
Int() int
Float() float64
@@ -28,6 +28,15 @@ func (h *HTML) Node() SyscallJSValue {
return htmlNodeImpl(h)
}

// RenderIntoNode renders the given component into the existing HTML element by
// replacing it.
//
// If the Component's Render method does not return an element of the same type,
// an error of type ElementMismatchError is returned.
func RenderIntoNode(node SyscallJSValue, c Component) error {
return renderIntoNode("RenderIntoNode", node, c)
}

var (
global jsObject
undefined wrappedObject
@@ -563,6 +563,8 @@ func TestRerender_identical(t *testing.T) {

ts.ints.mock(`global.Call("requestAnimationFrame", func)`, 0)
ts.strings.mock(`global.Get("document").Get("readyState")`, "complete")
ts.strings.mock(`global.Get("document").Call("querySelector", "body").Get("nodeName")`, "body")
ts.truthies.mock(`global.Get("document").Call("querySelector", "body")`, true)

// Perform the initial render of the component.
render := Tag("body")
@@ -650,6 +652,8 @@ func TestRerender_change(t *testing.T) {

ts.ints.mock(`global.Call("requestAnimationFrame", func)`, 0)
ts.strings.mock(`global.Get("document").Get("readyState")`, "complete")
ts.strings.mock(`global.Get("document").Call("querySelector", "body").Get("nodeName")`, "body")
ts.truthies.mock(`global.Get("document").Call("querySelector", "body")`, true)

// Perform the initial render of the component.
render := Tag("body")
@@ -753,6 +757,8 @@ func TestRerender_Nested(t *testing.T) {

ts.ints.mock(`global.Call("requestAnimationFrame", func)`, 0)
ts.strings.mock(`global.Get("document").Get("readyState")`, "complete")
ts.strings.mock(`global.Get("document").Call("querySelector", "body").Get("nodeName")`, "body")
ts.truthies.mock(`global.Get("document").Call("querySelector", "body")`, true)

// Perform the initial render of the component.
var renderCalled, skipRenderCalled int
@@ -851,6 +857,8 @@ func TestRerender_persistent(t *testing.T) {

ts.ints.mock(`global.Call("requestAnimationFrame", func)`, 0)
ts.strings.mock(`global.Get("document").Get("readyState")`, "complete")
ts.strings.mock(`global.Get("document").Call("querySelector", "body").Get("nodeName")`, "body")
ts.truthies.mock(`global.Get("document").Call("querySelector", "body")`, true)

lastRenderedComponent = nil
renderCount = 0
@@ -913,6 +921,8 @@ func TestRerender_persistent_direct(t *testing.T) {

ts.ints.mock(`global.Call("requestAnimationFrame", func)`, 0)
ts.strings.mock(`global.Get("document").Get("readyState")`, "complete")
ts.strings.mock(`global.Get("document").Call("querySelector", "body").Get("nodeName")`, "body")
ts.truthies.mock(`global.Get("document").Call("querySelector", "body")`, true)

lastRenderedComponent = nil
renderCount = 0
@@ -959,24 +969,27 @@ func TestRenderBody_ExpectsBody(t *testing.T) {
{
name: "text",
render: Text("Hello world!"),
wantPanic: "vecty: RenderBody expected Component.Render to return a body tag, found \"\"", // TODO(slimsag): error message bug
wantPanic: "vecty: RenderBody: expected Component.Render to return a \"body\", found \"\"", // TODO(slimsag): error message bug
},
{
name: "div",
render: Tag("div"),
wantPanic: "vecty: RenderBody expected Component.Render to return a body tag, found \"div\"",
wantPanic: "vecty: RenderBody: expected Component.Render to return a \"body\", found \"div\"",
},
{
name: "nil",
render: nil,
wantPanic: "vecty: RenderBody expected Component.Render to return a body tag, found \"noscript\"",
wantPanic: "vecty: RenderBody: expected Component.Render to return a \"body\", found \"noscript\"",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
ts := testSuite(t)
defer ts.done()

ts.strings.mock(`global.Get("document").Call("querySelector", "body").Get("nodeName")`, "body")
ts.truthies.mock(`global.Get("document").Call("querySelector", "body")`, true)

var gotPanic string
func() {
defer func() {
@@ -993,7 +1006,7 @@ func TestRenderBody_ExpectsBody(t *testing.T) {
})
}()
if c.wantPanic != gotPanic {
t.Fatalf("want panic %q got panic %q", c.wantPanic, gotPanic)
t.Fatalf("want panic:\n%q\ngot panic:\n%q", c.wantPanic, gotPanic)
}
})
}
@@ -1005,6 +1018,8 @@ func TestRenderBody_RenderSkipper_Skip(t *testing.T) {
ts := testSuite(t)
defer ts.done()

ts.truthies.mock(`global.Get("document").Call("querySelector", "body")`, true)

comp := &componentFunc{
render: func() ComponentOrHTML {
return Tag("body")
@@ -1018,7 +1033,7 @@ func TestRenderBody_RenderSkipper_Skip(t *testing.T) {
got := recoverStr(func() {
RenderBody(comp)
})
want := "vecty: RenderBody Component.SkipRender illegally returned true"
want := "vecty: RenderBody: Component.SkipRender illegally returned true"
if got != want {
t.Fatalf("got panic %q want %q", got, want)
}
@@ -1033,6 +1048,8 @@ func TestRenderBody_Standard_loaded(t *testing.T) {

ts.strings.mock(`global.Get("document").Get("readyState")`, "loaded")
ts.ints.mock(`global.Call("requestAnimationFrame", func)`, 0)
ts.strings.mock(`global.Get("document").Call("querySelector", "body").Get("nodeName")`, "body")
ts.truthies.mock(`global.Get("document").Call("querySelector", "body")`, true)

RenderBody(&componentFunc{
render: func() ComponentOrHTML {
@@ -1050,6 +1067,8 @@ func TestRenderBody_Standard_loading(t *testing.T) {

ts.strings.mock(`global.Get("document").Get("readyState")`, "loading")
ts.ints.mock(`global.Call("requestAnimationFrame", func)`, 0)
ts.strings.mock(`global.Get("document").Call("querySelector", "body").Get("nodeName")`, "body")
ts.truthies.mock(`global.Get("document").Call("querySelector", "body")`, true)

RenderBody(&componentFunc{
render: func() ComponentOrHTML {
@@ -1069,6 +1088,8 @@ func TestRenderBody_Nested(t *testing.T) {

ts.strings.mock(`global.Get("document").Get("readyState")`, "complete")
ts.ints.mock(`global.Call("requestAnimationFrame", func)`, 0)
ts.strings.mock(`global.Get("document").Call("querySelector", "body").Get("nodeName")`, "body")
ts.truthies.mock(`global.Get("document").Call("querySelector", "body")`, true)

RenderBody(&componentFunc{
render: func() ComponentOrHTML {
@@ -1109,6 +1130,8 @@ func TestKeyedChild_DifferentType(t *testing.T) {

ts.ints.mock(`global.Call("requestAnimationFrame", func)`, 0)
ts.strings.mock(`global.Get("document").Get("readyState")`, "complete")
ts.strings.mock(`global.Get("document").Call("querySelector", "body").Get("nodeName")`, "body")
ts.truthies.mock(`global.Get("document").Call("querySelector", "body")`, true)

comp := &componentFunc{
render: func() ComponentOrHTML {
@@ -21,6 +21,15 @@ func (h *HTML) Node() js.Value {
return h.node.(wrappedObject).j
}

// RenderIntoNode renders the given component into the existing HTML element by
// replacing it.
//
// If the Component's Render method does not return an element of the same type,
// an error of type ElementMismatchError is returned.
func RenderIntoNode(node js.Value, c Component) error {
return renderIntoNode("RenderIntoNode", wrapObject(node), c)
}

var (
global = wrapObject(js.Global())
undefined = wrappedObject{js.Undefined()}
@@ -103,6 +112,10 @@ func (w wrappedObject) String() string {
return w.j.String()
}

func (w wrappedObject) Truthy() bool {
return w.j.Truthy()
}

func (w wrappedObject) Bool() bool {
return w.j.Bool()
}
@@ -1,4 +1,6 @@
global.Get("document")
global.Get("document").Call("querySelector", "body")
global.Get("document")
global.Get("document").Call("createElement", "body")
global.Get("document").Call("createElement", "body").Get("classList")
global.Get("document").Call("createElement", "body").Get("dataset")
@@ -9,9 +11,11 @@ global.Get("document").Call("createElement", "tag1").Get("classList")
global.Get("document").Call("createElement", "tag1").Get("dataset")
global.Get("document").Call("createElement", "tag1").Get("style")
global.Get("document").Call("createElement", "body").Call("appendChild", jsObject(global.Get("document").Call("createElement", "tag1")))
global.Get("document").Call("querySelector", "body").Get("nodeName")
global.Get("document")
global.Get("document").Get("readyState")
global.Get("document").Set("body", jsObject(global.Get("document").Call("createElement", "body")))
global.Get("document").Call("querySelector", "body").Get("parentNode")
global.Get("document").Call("querySelector", "body").Get("parentNode").Call("replaceChild", jsObject(global.Get("document").Call("createElement", "body")), jsObject(global.Get("document").Call("querySelector", "body")))
global.Call("requestAnimationFrame", func)
global.Get("document").Call("createElement", "body").Get("classList")
global.Get("document").Call("createElement", "body").Get("dataset")
@@ -1,5 +1,8 @@
global.Get("document")
global.Get("document").Call("querySelector", "body")
global.Get("document")
global.Get("document").Call("createElement", "div")
global.Get("document").Call("createElement", "div").Get("classList")
global.Get("document").Call("createElement", "div").Get("dataset")
global.Get("document").Call("createElement", "div").Get("style")
global.Get("document").Call("createElement", "div").Get("style")
global.Get("document").Call("querySelector", "body").Get("nodeName")
@@ -1,5 +1,8 @@
global.Get("document")
global.Get("document").Call("querySelector", "body")
global.Get("document")
global.Get("document").Call("createElement", "noscript")
global.Get("document").Call("createElement", "noscript").Get("classList")
global.Get("document").Call("createElement", "noscript").Get("dataset")
global.Get("document").Call("createElement", "noscript").Get("style")
global.Get("document").Call("createElement", "noscript").Get("style")
global.Get("document").Call("querySelector", "body").Get("nodeName")
@@ -1,5 +1,8 @@
global.Get("document")
global.Get("document").Call("querySelector", "body")
global.Get("document")
global.Get("document").Call("createTextNode", "Hello world!")
global.Get("document").Call("createTextNode", "Hello world!").Get("classList")
global.Get("document").Call("createTextNode", "Hello world!").Get("dataset")
global.Get("document").Call("createTextNode", "Hello world!").Get("style")
global.Get("document").Call("createTextNode", "Hello world!").Get("style")
global.Get("document").Call("querySelector", "body").Get("nodeName")
@@ -1,9 +1,13 @@
global.Get("document")
global.Get("document").Call("querySelector", "body")
global.Get("document")
global.Get("document").Call("createElement", "body")
global.Get("document").Call("createElement", "body").Get("classList")
global.Get("document").Call("createElement", "body").Get("dataset")
global.Get("document").Call("createElement", "body").Get("style")
global.Get("document").Call("querySelector", "body").Get("nodeName")
global.Get("document")
global.Get("document").Get("readyState")
global.Get("document").Set("body", jsObject(global.Get("document").Call("createElement", "body")))
global.Get("document").Call("querySelector", "body").Get("parentNode")
global.Get("document").Call("querySelector", "body").Get("parentNode").Call("replaceChild", jsObject(global.Get("document").Call("createElement", "body")), jsObject(global.Get("document").Call("querySelector", "body")))
global.Call("requestAnimationFrame", func)
@@ -0,0 +1,2 @@
global.Get("document")
global.Get("document").Call("querySelector", "body")
ProTip! Use n and p to navigate between commits in a pull request.
You can’t perform that action at this time.