Skip to content
/ fuss Public

Functional strongly-typed streams based reactive framework and tools

License

Notifications You must be signed in to change notification settings

dotchain/fuss

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Fuss

Status GoDoc codecov Go Report Card

FUSS is an experimental functional/reactive framework using strongly-typed streams.

Goals

The main goal is to build a reactive UX framework which:

  1. Composes nicely: pure functions are the typical case
  2. Uses strong static typing: compiler errors with wrong usage
  3. Uses streams as the basis for time-varying data
  4. Very little magic in the framework
  5. Framework can co-exist with non-reactive or other implementations of reactive code.

Status

The project is experimental and all interfaces may change

Contents

  1. Goals
  2. Status
  3. TODO MVC Example
    1. Todo and TodoList
    2. Generating the streams types
    3. The Todo View
    4. Input
    5. The filtered list
    6. Change handling and stateful components
    7. Generating the factory code
    8. Collaborative demo
  4. Limitations

TODO MVC Example

The demo TODO MVC app is built out of the todo folder.

Todo and TodoList

The core data type for the todo app is a simple struct to hold the todo item and a slice to represent a collection of these:

// Todo represents an item in the TODO list.
type Todo struct {
	ID          string
	Complete    bool
	Description string
}

// TodoList represents a collection of todos
type TodoList []Todo

Generating the streams types

The code generation tool dotc can be used to augment the types here with additional methods. In particular, much of the UI will use streams of these values.

func generateTypes() {
	_, self, _, _ := runtime.Caller(0)
	output := filepath.Join(filepath.Dir(self), "generated1.go")

	info := dotc.Info{
		Package: "todo",
		Structs: []dotc.Struct{{
			Recv: "t",
			Type: "Todo",
			Fields: []dotc.Field{{
				Name: "Complete",
				Key:  "complete",
				Type: "bool",
			}, {
				Name: "Description",
				Key:  "desc",
				Type: "string",
			}},
		}},
		Slices: []dotc.Slice{{
			Recv:     "t",
			Type:     "TodoList",
			ElemType: "Todo",
		}},
	}
	code, err := info.Generate()
	if err != nil {
		panic(err)
	}
	err = ioutil.WriteFile(output, []byte(code), 0644)
	if err != nil {
		panic(err)
	}
}

See Codegen where this is used.

The TodoStream type exposes the current value via the Value field and supports an Update() method to update the value whole-sale. Such updates do not change the current value but can be thought of as appending to the sequence of values a particular Todo has. The latest value can be obtained via TodoStream:Latest() and callers can register for notifications on it.

More interestingly, the code snippet above will generate methods on the stream to fetch sub-streams for, say, the Complete field. This sub-stream holds a simple bool value and when it is updated, the corresponding todo itself is updated with that field changed. The stream also does "merging" -- if the Complete and Description fields were modified indepdently, the latest of the todo will be merge of the changes.

The TodoListStream similarly exposes a TodoStream for each element in the array and edits to the individual elements correectly get propagated to the parent.

The Todo View

The following snippet renders a single todo item:

// Todo renders a Todo item
func todo(deps *todoDeps, todoStream *TodoStream) dom.Element {
	return deps.run(
		"root",
		dom.Styles{},
		deps.checkboxEdit("cb", dom.Styles{}, todoStream.Complete(), ""),
		deps.textEdit("textedit", dom.Styles{}, todoStream.Description()),
	)
}

type TodoFunc = func(key interface{}, todoStream *TodoStream) dom.Element
type todoDeps struct {
	run          dom.RunFunc
	checkboxEdit dom.CheckboxEditFunc
	textEdit     dom.TextEditFunc
}

Each component has three parts: the core function (todo(..)), the set of dependencies used by this function (todoDeps) and a signature for how callers can use this function TodoFunc.

A few notes:

  1. The first argument of the function must be a pointer to the dependency struct
  2. The dependency struct should hold all the other components this one intends to use. In particular, the function signatures are named here.
  3. The signature replaces the first arg with a generic "key" (which will be explained shortly).

The main function itself creates sub-components as needed using the dependencies struct. The code-generation framework produces a single artifact for each compnent -- a factory function that creates instances of the component:

func NewTodo() (update TodoFunc, close func()) {
    ...
}

The idea is that any consumer of the todo component would use this function above to create an instance. The generated function implements the logic for creating the dependency functions. In particular, the dependency functions take a "key" as the first parameter (with the rest matching the corresponding component). The generated scaffolding checks if the key was used before and if so reuses the last instance (calling on its update method). If the key is new, it calls the factory method of the sub component.

The generated factory function also does simple memoization: if the args to the update method are same as before, it results the results from the last round.

Input

Going back to the example above, the checkbox was provided with a streams.Bool which controls both whether it shows as checked or not as well as the output from the checkbox. When the user toggles the checkbox, the checkbox component updates the provided input stream by calling streams.Bool:Update(newValue) on it.

The todo view does not directly handle this but since the checkbox stream was created via todoStream.Complete() any changes to the boolean stream end up modifying the corresponding todoStream

These changes to the stream do not cause any automatic re-rendering though. Instead the are propagated up until some point where the stream is considered the state of a component.

The filtered list

The parent of the todo() view is a list of todos (filtered by whether they are active or not based on a filter setting)

// FilteredList renders  a list of filtered todos
//
// Individual tasks can be modified underneath.
func filteredList(deps *filteredListDeps, filter *streams.S16, todos *TodoListStream) dom.Element {
	return deps.vRun(
		"root",
		dom.Styles{},
		todos.Value.renderTodo(func(index int, t Todo) dom.Element {
			done := filter.Value == controls.ShowDone
			active := filter.Value == controls.ShowActive
			if t.Complete && active || !t.Complete && done {
				return nil
			}

			return deps.todo(t.ID, todos.Item(index))
		})...,
	)
}

type FilteredListFunc = func(key interface{}, filter *streams.S16, todos *TodoListStream) dom.Element
type filteredListDeps struct {
	vRun dom.VRunFunc
	todo TodoFunc
}

This code creates a VRun flex-rows container (using dom.VRun) but also takes a stream of TodoList as the arg. It walks through each element of the TaskList value and creates a child todo component (assuming the filter conditions were satisfied).

The dependency struct here indicates both sub-components are needed. The code also uses the task ID as the key when invoking deps.todo(t.ID, ...). This ensures that if an item gets shuffled around, it will be reused still.

Change handling and stateful components

So far there is no specific code for change handling. The changes from checkboxes and textedits simply propagaate upwards but no automatic re-rendering happens until it hits a stateful component. A stateful component takes a state parameter as well as returns it. The generated factory function automatically rerenders a component when its state changes.

An example of such a component is the full app itself: the list of todos are maintained as state (though in a real app, they would be saved on the server but thats a different demo):

// App hosts the todo MVC app
func app(deps *appDeps, state *TodoListStream) (*TodoListStream, dom.Element) {
	if state == nil {
		// TODO: fetch this from the network
		state = &TodoListStream{
			Stream: streams.New(),
			Value: TodoList{
				Todo{"one", true, "First task"},
				Todo{"two", false, "Second task"},
			},
		}
	}

	return state, deps.chrome(
		"root",
		deps.textView("h", dom.Styles{}, "FUSS TODO"),
		deps.listView("root", state),
		deps.a(
			"a",
			dom.Styles{},
			"https://github.com/dotchain/fuss",
			deps.textView("tv", dom.Styles{}, "github"),
		),
	)
}

type AppFunc = func(key interface{}) dom.Element
type appDeps struct {
	textView dom.TextViewFunc
	listView ListViewFunc
	a        dom.AFunc
	chrome   controls.ChromeFunc
}

As describe before, the distinguishing marks of a stateful component are:

  1. It has a parameter that captures the state (these should be named xyzState).

  2. It returns the state back. At a minimum, this is needed for initialization (state is initially the zero value for its type)

When the checkbox stream is updated, that gets propagated all the way up to the app where the TodoListStream is the state. So, the app component is re-rendered and effectively this causes the whole sub-tree to be re-rendered (unless a sub-component has the same args as before, in which case the previous results are reused).

Generating the factory code

The factory code can be fairly completely auto-generated with a simple stub like this:

func generateComponents() {
	_, self, _, _ := runtime.Caller(0)
	output := filepath.Join(filepath.Dir(self), "generated2.go")
	skip := []string{"generated2.go"}
	info, err := fussy.ParseDir(filepath.Dir(self), "todo", skip)
	if err != nil {
		panic(err)
	}
	err = ioutil.WriteFile(output, []byte(fussy.Generate(*info)), 0644)
	if err != nil {
		panic(err)
	}
}

See Codegen where this is used.

Collaborative demo

The example in the collab folder can be used to demonstrate a collaborative todo list:

Starting the local server:

$ cd github.com/dotchain/fuss/todo/collab
$ go run server

Starting the gopherjs session:

$ gopherjs serve github.com/dotchain/fuss/todo/collab --http=:8081

Now open multiple browser tabs to http://localhost:8081 and see that they all share the same TODO list.

The actual todo list is served from a local file (todo.bolt) and so the state persists even when the browser session is restarted.

Limitations

There are a lot of limitations with this still:

  1. The xyzFunc types must all use named args (as these names are used within the generated code). State args should not appear on this Func type.

  2. The dependency function can be named anything as its type is deduced from the first parameter but the xyzFunc declaration should match the function name.

  3. The actual core function (such as todo in the example) should always be unexported. This is also true for the dependency structures. The xyzFunc declaration can be exported or private and the generated NewXyz() will mirror this.

  4. Memoization works by comparing using == with one exception: variadic types are handled by checking each value instead of the slice. Non-comparable types can implement Equals(other) methods to provide custom equals methods.

  5. The generated code assumes it lives in the same package. But the actual code generation should skip this file (the generation code snippets in this README file correctly deal with this).

  6. The dotc code generator needs to be explicitly provided with the type info -- it is not deduced from the code yet. This is rather finicky, particularly if dealing with struct of structs and such as it depends on the dot infrastructure.

  7. The gopherjs bundle is huge. None of the underlying components have been optimized in any way for the bundle.