fuse-open / fuselibs

Fuselibs is the Uno-libraries that provide the UI framework used in Fuse apps
https://npmjs.com/package/@fuse-open/fuselibs
MIT License
176 stars 72 forks source link

Reactive Modules API (proposal) #214

Closed Duckers closed 7 years ago

Duckers commented 7 years ago

Posted here for discussion:

Reactive modules

A module related with a <JavaScript> tag supports the Reactive Modules API. This is a set of methods on module that allows the module to change the exported values and notifying the data context about the change. This allows reactive programming between modules.

Each of these APIs accept a path to the node in the exports tree. The path is a sequence of either string keys (to look up into objects) or number indices (to look up into arrays).

module.set(... path, value)

Sets the value at the path to value. Example:

exports.foo = [ {bar: "apples"}, {bar: "oranges"}]

exports.changeSomething = function() {
    // Changes the 'bar' property of the second item in the 'foo' array
    module.set("foo", 1, "bar", "bananas");
}

module.add(... path, item)

Adds item to the end of the array at the path. Example:

exports.user = { name: 'Bob', things: ["lamp", "car"] }

exports.addSomething = function() 
{
    // Adds 'airplane' to the user's list of things
    module.add("user", "things", "airplane");
}

module.removeAt(... path)

Removes the item at the path. The last item in the path must be a numeric index into an array. Example:

exports.user = { name: 'Bob', things: ["lamp", "car"] }

exports.removeSomething = function() 
{
    // Removes 'lamp' from users's list of things
    module.removeAt("user", "things", 0);
}

module.insertAt(... path, value)

Inserts value at the path. The last item in the path must be a numeric index into an array. Example:

exports.user = { name: 'Bob', things: ["lamp", "car"] }

exports.insertSomething = function() 
{
    // Inserts 'dog' in the middle of the user's list of things
    module.insertAt("user", "things", 1, "dog");
}
mortoray commented 7 years ago

I think we should go for a more natural API than this variables arguments syntax (though lacking ES6 we have to lose a few niceties).

For example:

module.get("user").get("things").add("airplane")

You can store this accessors as variables (they look like observables, but are actually just wrappers to modify the backing data)

var user = module.get("user")
user.get("things").insertAt(1, "dog")

I'm not sure get is the correct name, mabye val orvar instead. But the syntax is a lot more readable. I can understand what is happening here.

Duckers commented 7 years ago

@COCPORN and myself spent 3 months this spring evaluating different API designs, and concluded that the variable arguments one is the nicest, for various reasons. It is easy to build the API you are proposing (and many other APIs) on top as an abstraction layer. This is the low-level API with the minimal JS->Native API surface and overhead.

Duckers commented 7 years ago

For example, with this low-level API it is easy to build a differ-based component system similar to Angular

kristianhasselknippe commented 7 years ago

The propsed API looks totally fine to me :)

Sunjammer commented 7 years ago

I'd prefer to have the path argument be an array rather than leaning on arguments again. This makes a path a type, simplifies the method signature, and lets a path be referenced instead of done with literals or currying, if that is a design choice I want to make.

I also think it will teach and document better.

module.set(["foo", "bar"], true);

Other than that I think this is golden.

Duckers commented 7 years ago

Again, this can be a convenience API on top. I want to keep the base API as minimal as possible

Sunjammer commented 7 years ago

I feel like the varargs based API performs more work in terms of manipulating the arguments.

kristianhasselknippe commented 7 years ago

I have to agree with @Sunjammer here. Doing it with an array seems a lot less magical.

Duckers commented 7 years ago

Candidate implementation found here: https://github.com/fusetools/fuselibs-public/pull/208

Duckers commented 7 years ago

I think this looks really weird:

module.removeAt(["foo", "bar", 1])

The array looks very redundant then

Sunjammer commented 7 years ago

I agree, it should be module.removeAt(["foo","bar"], 1)

Duckers commented 7 years ago

Well, that messes it up, as 1 is part of the path. That would be very inconsistent

Sunjammer commented 7 years ago

Not if you imagine the path as terminating at an array, removeAt as a function that operates on arrays, and 1 to be the index at which to remove. Then your statement is the straightforward do X to the item at path Y using N arguments.

This is consistent: At path X, set Y. At path X, remove at index Y. At path X, insert Y at index Z.

Duckers commented 7 years ago

Well I'm still in favor of the varargs api.

A rewrite would mean this feature not making it to 1.2 as that deadline is tonight.

Duckers commented 7 years ago

There is no issue introducing an "overload" later that accepts an array for path. No difficulties creating a wrapper api on top either.

Sunjammer commented 7 years ago

Yep, that can be easily implemented at a later point as checking if argument 0 is an array anyway

eksperts commented 7 years ago

More of a question, but how would you handle a bit more complex structure? Something like this:

exports.list = [
    {
        userId: 12,
        userName: "User 1",
        properties: {
            permissions: ["read"],
            groups: [222, 333, 444]
        }
    },
    {
        userId: 15,
        userName: "User 2",
        properties: {
            permissions: ["read", "write", "execute"],
            groups: [222, 333, 444]
        }
    }
];
exports.addGroupToUser() = function(userId, groupId) {
    // how would I add group 555 to userId 15?
}
Duckers commented 7 years ago

@eksperts :

With the raw API, that would be something like

module.add("list", exports.list.indexof(...), "properties", "groups", groupId)

However, the intention is not for this API to be used much directly. It is meant as a low-level API that we build other features on top.

For example, something like:

var Component = require("FuseJS/Component")

Component(module, function() {
     this.list = [ ... ]
     this.addGroupToUser = function() {
        // Here, simply modify the list, and the component will update automatically through diffing
     }
})

Hence, I'm not too worried about what the low-level API looks like, as long as it is lighweight and general purpose.

Duckers commented 7 years ago

Note that in the above example, Component can be implemented as a pure JS utility class

eksperts commented 7 years ago

Alright, no objections. I see a point in @Sunjammer's argument, but other than that it's legit!

mortoray commented 7 years ago

I just looked at the PR for this and am concered about adding these simple names directly to the module object. I know it's our object, but seeing sometihng like module.set and module.add makes it seem so "standard", yet it's not.

mortoray commented 7 years ago

Does this use of exports prevent anything else from being put in the exports? Can I still add callback functions as normal? Or can I use an OBservable as well?

mortoray commented 7 years ago

The examples only go one-level deep with the JS object trees. IS an arbitrary depth intended?

Duckers commented 7 years ago

makes it seem so "standard", yet it's not.

I agree, however, rooted modules in <JavaScript> tags are special, its not vanilla CommonJS, so it doesn't hurt to let it show. The modules have a life time, interact with the data context, can be instantiated multiple times etc. We already have module.disposed. It is the natural place to put things.

Duckers commented 7 years ago

The examples only go one-level deep with the JS object trees. IS an arbitrary depth intended?

Yes

Duckers commented 7 years ago

Does this use of exports prevent anything else from being put in the exports? Can I still add callback functions as normal? Or can I use an OBservable as well?

You can use Observables anywhere, like before. No feature regression

mortoray commented 7 years ago

I'm not clear on how I share state between modules. I have a list of users that I will need to display in multiple pages. I additionally have shared user objects in that list. They also have to appear on their on other pages. I don't see how this is done in this framework.

Duckers commented 7 years ago

I'm not clear on how I share state between modules.

You have to use the dependency injection feature to acheive that:

<JavaScript dep:users="{users}">

This imports the users list from the data context

mortoray commented 7 years ago

This means any structures I wish to store have to be exported at the root of the app. I'm not sure I like that since it makes modularization more difficult.

How do I export part of that structure in another module then, that is share the data?

<JavaScript dep:users="{users}" dep:param="parameter()">
    exports.user = users[param.user_id]
Duckers commented 7 years ago

This means any structures I wish to store have to be exported at the root of the app.

Not true - you can mount this at any level in the tree.

Duckers commented 7 years ago

This makes modularization easier as the data is tree-local. For example, a component can expose local data that is used within its subtree only.

Duckers commented 7 years ago

How do I export part of that structure in another module then, that is share the data?

Didn't understand the question

Duckers commented 7 years ago
exports.user = users[param.user_id]

Alternatively:

<JavaScript dep:user="{users[parameter().user_id]}">
mortoray commented 7 years ago

What I mean is that any value I wish to use in sibling components must be mounted at a higher level.

If I do <JavaScript dep:user="{users[parameter().user_id]}"> will this be a proper two-way binding to this object?

How can I do a similar binding at the JS level?

I'm missing the insight into how one actually structures the data of an app using this system.

Another example, in the apps I've written so far I have something like a user DB stored in memory. You call a JS function getUser(id) that provides an Observable for that user. I don't see how to create such a function with this new system.

Duckers commented 7 years ago

What I mean is that any value I wish to use in sibling components must be mounted at a higher level.

Yes, this creates a natural component-local data sharing scheme. If you want true globals, nothing is stopping you from doing the old :Bundle and require() trick.

If I do will this be a proper two-way binding to this object?

This is a one-way binding, but you will get the actual object and are free to modify it. However, this won't call the reactive module API, so any changes you do remotely won't be reflected. Mutation of imported state should happen through functions exported by the owning module.

How can I do a similar binding at the JS level?

You cannot. This is what the dependency injection system is for.

I'm missing the insight into how one actually structures the data of an app using this system.

We have detailed plans for this. Once this is merged we can write new best practices docs.

Another example, in the apps I've written so far I have something like a user DB stored in memory. You call a JS function getUser(id) that provides an Observable for that user. I don't see how to create such a function with this new system.

You can still provide observables if you wish, but the whole point is to reduce reliance on observables.

In this system you would for example do ` . In the future when we have a UX syntax for calling JS functions, you can even call the function with a reactive expression.

Duckers commented 7 years ago

The high level recommended structure is as follows:

<App>
    <JavaScript>
        // This is the main state container module - like a redux store
        // it contains and exports all the app-global state, and exports
        // functions for all the possible transactions

        exports.things = ["one", "two", "three"]

        exports.addThing(thing) {
            module.add("things", thing)
        }
    </JavaScript>
    <Navigator>
        <Page>
            <JavaScript dep:things="{things}" dep:addThing="{addThing}" >

                // This module can compute derived state used 
                // locally on this Page
                exports.thingCount = things.length

                exports.buttonClicked = function() {
                    addThing("lol")
                }
            </JavaScript>
mortoray commented 7 years ago

How does one bridge from a require module into this system? Say I have a user module that I want several components to have access to. How do I actually structure this sharing?

Try to extend your example to include several modules. Say we have three modules, user, task and config. How would we instantiate all three of these and provide them to a component?

Duckers commented 7 years ago

Something like this?

<App>
    <JavaScript>
        exports.user = require("MyApp/user")
        exports.task = require("MyApp/task")
        exports.config = require("MyApp/config")
    </JavaScript>
    <Navigator>
        <Page>
            <JavaScript dep:user="{user}">
                ...
Duckers commented 7 years ago

The nice thing about this is that now it is easy to mock the environment for the script in the page for testing purposes.

Duckers commented 7 years ago

If you want the modules to have access to the data context, you can do:


    <JavaScript File="MyApp/user.js" />
    <JavaScript File="MyApp/task.js" />
    <JavaScript File="MyApp/config.js" />
    <Navigator>
        <Page>
            <JavaScript dep:user="{user}">
                ...```
Duckers commented 7 years ago

Okay, after a long discussion in chat with @mortoray we've decided to redesign this feature slightly.

I will open a new ticket to discuss the new proposed design.