waveterm/tsunami/engine/render.go
2025-11-07 18:19:52 -08:00

325 lines
8.4 KiB
Go

// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package engine
import (
"fmt"
"reflect"
"unicode"
"github.com/google/uuid"
"github.com/wavetermdev/waveterm/tsunami/rpctypes"
"github.com/wavetermdev/waveterm/tsunami/util"
"github.com/wavetermdev/waveterm/tsunami/vdom"
)
// see render.md for a complete guide to how tsunami rendering, lifecycle, and reconciliation works
type RenderOpts struct {
Resync bool
}
func (r *RootElem) Render(elem *vdom.VDomElem, opts *RenderOpts) {
r.render(elem, &r.Root, "root", opts)
}
func getElemKey(elem *vdom.VDomElem) string {
if elem == nil {
return ""
}
keyVal, ok := elem.Props[vdom.KeyPropKey]
if !ok {
return ""
}
return fmt.Sprint(keyVal)
}
func (r *RootElem) render(elem *vdom.VDomElem, comp **ComponentImpl, containingComp string, opts *RenderOpts) {
if elem == nil || elem.Tag == "" {
r.unmount(comp)
return
}
elemKey := getElemKey(elem)
if *comp == nil || !(*comp).compMatch(elem.Tag, elemKey) {
r.unmount(comp)
r.createComp(elem.Tag, elemKey, containingComp, comp)
}
(*comp).Elem = elem
if elem.Tag == vdom.TextTag {
// Pattern 1: Text Nodes
r.renderText(elem.Text, comp)
return
}
if isBaseTag(elem.Tag) {
// Pattern 2: Base elements
r.renderSimple(elem, comp, containingComp, opts)
return
}
cfunc := r.CFuncs[elem.Tag]
if cfunc == nil {
text := fmt.Sprintf("<%s>", elem.Tag)
r.renderText(text, comp)
return
}
// Pattern 3: components
r.renderComponent(cfunc, elem, comp, opts)
}
// Pattern 1
func (r *RootElem) renderText(text string, comp **ComponentImpl) {
// No need to clear Children/Comp - text components cannot have them
if (*comp).Text != text {
(*comp).Text = text
}
}
// Pattern 2
func (r *RootElem) renderSimple(elem *vdom.VDomElem, comp **ComponentImpl, containingComp string, opts *RenderOpts) {
if (*comp).RenderedComp != nil {
// Clear Comp since base elements don't use it
r.unmount(&(*comp).RenderedComp)
}
(*comp).Children = r.renderChildren(elem.Children, (*comp).Children, containingComp, opts)
}
// Pattern 3
func (r *RootElem) renderComponent(cfunc any, elem *vdom.VDomElem, comp **ComponentImpl, opts *RenderOpts) {
if (*comp).Children != nil {
// Clear Children since custom components don't use them
for _, child := range (*comp).Children {
r.unmount(&child)
}
(*comp).Children = nil
}
props := make(map[string]any)
for k, v := range elem.Props {
props[k] = v
}
props[ChildrenPropKey] = elem.Children
vc := makeContextVal(r, *comp, opts)
rtnElemArr := withGlobalRenderCtx(vc, func() []vdom.VDomElem {
renderedElem := callCFuncWithErrorGuard(cfunc, props, elem.Tag)
return vdom.ToElems(renderedElem)
})
// Process atom usage after render
r.updateComponentAtomUsage(*comp, vc.UsedAtoms)
var rtnElem *vdom.VDomElem
if len(rtnElemArr) == 0 {
rtnElem = nil
} else if len(rtnElemArr) == 1 {
rtnElem = &rtnElemArr[0]
} else {
rtnElem = &vdom.VDomElem{Tag: vdom.FragmentTag, Children: rtnElemArr}
}
r.render(rtnElem, &(*comp).RenderedComp, elem.Tag, opts)
}
func (r *RootElem) unmount(comp **ComponentImpl) {
if *comp == nil {
return
}
waveId := (*comp).WaveId
for _, hook := range (*comp).Hooks {
if hook.UnmountFn != nil {
hook.UnmountFn()
}
}
if (*comp).RenderedComp != nil {
r.unmount(&(*comp).RenderedComp)
}
if (*comp).Children != nil {
for _, child := range (*comp).Children {
r.unmount(&child)
}
}
delete(r.CompMap, waveId)
r.cleanupUsedByForUnmount(*comp)
*comp = nil
}
func (r *RootElem) createComp(tag string, key string, containingComp string, comp **ComponentImpl) {
*comp = &ComponentImpl{WaveId: uuid.New().String(), Tag: tag, Key: key, ContainingComp: containingComp}
r.CompMap[(*comp).WaveId] = *comp
}
// handles reconcilation
// maps children via key or index (exclusively)
func (r *RootElem) renderChildren(elems []vdom.VDomElem, curChildren []*ComponentImpl, containingComp string, opts *RenderOpts) []*ComponentImpl {
newChildren := make([]*ComponentImpl, len(elems))
curCM := make(map[ChildKey]*ComponentImpl)
usedMap := make(map[*ComponentImpl]bool)
for idx, child := range curChildren {
if child.Key != "" {
curCM[ChildKey{Tag: child.Tag, Idx: 0, Key: child.Key}] = child
} else {
curCM[ChildKey{Tag: child.Tag, Idx: idx, Key: ""}] = child
}
}
for idx, elem := range elems {
elemKey := getElemKey(&elem)
var curChild *ComponentImpl
if elemKey != "" {
curChild = curCM[ChildKey{Tag: elem.Tag, Idx: 0, Key: elemKey}]
} else {
curChild = curCM[ChildKey{Tag: elem.Tag, Idx: idx, Key: ""}]
}
usedMap[curChild] = true
newChildren[idx] = curChild
r.render(&elem, &newChildren[idx], containingComp, opts)
}
for _, child := range curChildren {
if !usedMap[child] {
r.unmount(&child)
}
}
return newChildren
}
// safely calls the component function with panic recovery
func callCFuncWithErrorGuard(cfunc any, props map[string]any, componentName string) (result any) {
defer func() {
if panicErr := util.PanicHandler(fmt.Sprintf("render component '%s'", componentName), recover()); panicErr != nil {
result = renderErrorComponent(componentName, panicErr.Error())
}
}()
result = callCFunc(cfunc, props)
return result
}
// uses reflection to call the component function
func callCFunc(cfunc any, props map[string]any) any {
rval := reflect.ValueOf(cfunc)
rtype := rval.Type()
if rtype.NumIn() != 1 {
fmt.Printf("component function must have exactly 1 parameter, got %d\n", rtype.NumIn())
return nil
}
argType := rtype.In(0)
var arg1Val reflect.Value
if argType.Kind() == reflect.Interface && argType.NumMethod() == 0 {
arg1Val = reflect.New(argType)
} else {
arg1Val = reflect.New(argType)
if argType.Kind() == reflect.Map {
arg1Val.Elem().Set(reflect.ValueOf(props))
} else {
err := util.MapToStruct(props, arg1Val.Interface())
if err != nil {
fmt.Printf("error converting props: %v\n", err)
}
}
}
rtnVal := rval.Call([]reflect.Value{arg1Val.Elem()})
if len(rtnVal) == 0 {
return nil
}
return rtnVal[0].Interface()
}
func convertPropsToVDom(props map[string]any) map[string]any {
if len(props) == 0 {
return nil
}
vdomProps := make(map[string]any)
for k, v := range props {
if v == nil {
continue
}
if vdomFunc, ok := v.(vdom.VDomFunc); ok {
// ensure Type is set on all VDomFuncs
vdomFunc.Type = vdom.ObjectType_Func
vdomProps[k] = vdomFunc
continue
}
if vdomFuncPtr, ok := v.(*vdom.VDomFunc); ok {
if vdomFuncPtr == nil {
continue // handled typed-nil
}
// ensure Type is set on all VDomFuncs (pointer)
vdomFuncPtr.Type = vdom.ObjectType_Func
vdomProps[k] = vdomFuncPtr
continue
}
if vdomRef, ok := v.(vdom.VDomRef); ok {
// ensure Type is set on all VDomRefs
vdomRef.Type = vdom.ObjectType_Ref
vdomProps[k] = vdomRef
continue
}
if vdomRefPtr, ok := v.(*vdom.VDomRef); ok {
if vdomRefPtr == nil {
continue // handle typed-nil
}
// ensure Type is set on all VDomRefs (pointer)
vdomRefPtr.Type = vdom.ObjectType_Ref
vdomProps[k] = vdomRefPtr
continue
}
val := reflect.ValueOf(v)
if val.Kind() == reflect.Func {
// convert go functions passed to event handlers to VDomFuncs
vdomProps[k] = vdom.VDomFunc{Type: vdom.ObjectType_Func}
continue
}
vdomProps[k] = v
}
return vdomProps
}
func (r *RootElem) MakeRendered() *rpctypes.RenderedElem {
if r.Root == nil {
return nil
}
return r.convertCompToRendered(r.Root)
}
func (r *RootElem) convertCompToRendered(c *ComponentImpl) *rpctypes.RenderedElem {
if c == nil {
return nil
}
if c.RenderedComp != nil {
return r.convertCompToRendered(c.RenderedComp)
}
if len(c.Children) == 0 && r.CFuncs[c.Tag] != nil {
return nil
}
return r.convertBaseToRendered(c)
}
func (r *RootElem) convertBaseToRendered(c *ComponentImpl) *rpctypes.RenderedElem {
elem := &rpctypes.RenderedElem{WaveId: c.WaveId, Tag: c.Tag}
if c.Elem != nil {
elem.Props = convertPropsToVDom(c.Elem.Props)
}
for _, child := range c.Children {
childElem := r.convertCompToRendered(child)
if childElem != nil {
elem.Children = append(elem.Children, *childElem)
}
}
if c.Tag == vdom.TextTag {
elem.Text = c.Text
}
return elem
}
func isBaseTag(tag string) bool {
if tag == "" {
return false
}
if tag == vdom.TextTag || tag == vdom.WaveTextTag || tag == vdom.WaveNullTag || tag == vdom.FragmentTag {
return true
}
if tag[0] == '#' {
return true
}
firstChar := rune(tag[0])
return unicode.IsLower(firstChar)
}