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

Introduce plugin architecture. #124

Merged
merged 9 commits into from
Mar 4, 2017
Merged

Introduce plugin architecture. #124

merged 9 commits into from
Mar 4, 2017

Conversation

jorgebucaran
Copy link
Owner

@jorgebucaran jorgebucaran commented Mar 2, 2017

Plugins are used like this:

app({
  ...,
  plugins: [PluginFoo, PluginBar, PluginBaz]
})

In a nutshell, if you know how to make an app with HyperApp you already know how to make a plugin.

A plugin is a function that exports an object with the same props as the object you pass into app(options) (except views, plugins don't export views at the moment, but you can listen to the onRender hook to alter this process, see Router).

For example, a plugin that adds a new subscription to your app:

const MyPlugin = app => ({
    subscriptions: [ _ => console.log("Hi, I'm a plugin") ],
})

A plugin function is called by HyperApp during your app's init step (before rendering a view). The app argument is the same options object you pass into app(options). Plugins don't need to modify this object, instead they return an object that is merged back into your app's options.

Since this is JavaScript, plugins can totally modify this object's properties, so they have both the power to bring joy and devastation to the world.

I'll be updating the docs later, once this ships.


Also on this branch:

  • Entire test spec rewritten.
  • New Router based in the new plugin architecture.
  • New and faster h.
    • As a result, smaller app.js since we no longer need to handle bools/nulls in the vdom engine.

@jorgebucaran jorgebucaran added the enhancement New feature or request label Mar 2, 2017
@jorgebucaran jorgebucaran self-assigned this Mar 2, 2017
@codecov
Copy link

codecov bot commented Mar 2, 2017

Codecov Report

Merging #124 into master will not change coverage.
The diff coverage is 100%.

@@          Coverage Diff          @@
##           master   #124   +/-   ##
=====================================
  Coverage     100%   100%           
=====================================
  Files           3      4    +1     
  Lines         170    174    +4     
=====================================
+ Hits          170    174    +4
Impacted Files Coverage Δ
src/app.js 100% <100%> (ø)
src/router.js 100% <100%> (ø)
src/h.js 100% <100%> (ø)
test/util.js 100% <100%> (ø)

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 94ae5b4...f0503df. Read the comment docs.

@jorgebucaran
Copy link
Owner Author

@tunnckoCore @lukejacksonn Could you review this PR too? 🙏

@lukejacksonn
Copy link
Contributor

Sure, I will take a look this afternoon! 👍

src/router.js Outdated
],
hooks: {
onRender: function (model, view) {
console.log(model.router.location)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

console.log here 🔥

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Haha.

src/router.js Outdated
}
}

return routes["/"]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Were we going to be implementing the changes made in #113 to this PR?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better after this is landed 👍.

@tunnckoCore
Copy link

Too much set in one commit.. but i'll try. In first glance i have some notes.

Copy link
Contributor

@terkelg terkelg left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me!

src/app.js Outdated
onUpdate: Function.prototype,
onError: function (err) {
var onError = function (err) {
if (!hook("onError", function (fn) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is very strange to me.. Consider moving the callback function before the if?

var onerror = function (fn) { fn(err) }
if (!hook("onError", onerror)) {
  throw err
}

and in anyway it looks strange, but that's better

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's not going to work, because we pass onError to effects and subscriptions.

Copy link

@tunnckoCore tunnckoCore Mar 3, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wat? no, no. I not meant that. It's no matter how the var is named. The point is to not be inside the if condition

var foobar = function (fn) { fn(err) }
if (!hook("onError", foobar)) {
  throw err
}

and that (as currently) are the same thing

if (!hook("onError", function (fn) { fn(err) })) { // collapsed in one line
  throw err
}

Copy link
Owner Author

@jorgebucaran jorgebucaran Mar 3, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I'm missing something, but I still think this is not going to work when sharing onError with multiple plugins. This is a minor thing, so please send me a PR after we land this one 👍.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the new hook system this problem has gone away!

src/router.js Outdated
params[re.keys.shift()] = arguments[i]
}

match = function (model, actions) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move function out of the replace? Move it inside the outside if. It would be faster for sure. It may sounds like a super micro optimization, but not only that won't redefined that function on each found match, but it would looks a bit better and natural.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope. It isn't (looking at the latest commit) L54-56.

Simply make match = 1 and if there is no matches in that .replace() function match would be falsey value, otherwise will be 1.

path.replace(new RegExp(re.re, "g"), function () {
  for (var i = 1; i < arguments.length - 2; i++) {
    params[re.keys.shift()] = arguments[i]
  }

  match = 1
})

if (match) {
  return function (model, actions) {
    return routes[route](model, actions, params)
  }
}

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hehe, done, locally. I haven't pushed anything 😄.

src/router.js Outdated
}
}
match = function (model, actions) {
return routes[route](model, actions, params)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't params be in the model now?

Copy link

@FlorianWendelborn FlorianWendelborn Mar 3, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should actually also improve performance since match doesn't need to be executed on every render and instead just on every go. I'd not be surprised if this gives us -0.1 or more relative performance. (in addition to being less hacky and being easier to use)

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I understand what you are saying 🤔.

Shouldn't params be in the model now?

Not sure about this. location is in the model not because it's the only way for the Router to control when the view is rendered without using a hack like passing a render function as we were doing before.

Copy link

@FlorianWendelborn FlorianWendelborn Mar 3, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From #120:

model.router = {
	matched: '/some/route/:id',
	url: '/some/route/34593458',
	params: {
		id: 34593458
	}
}

Copy link

@FlorianWendelborn FlorianWendelborn Mar 3, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it makes more sense and it should also be faster, since right now we call match every time the model is updated. That function looks quite expensive. I propose that go also tells setLocation the match() results so that it can be added to params.

This also has the advantage that actions may become easier since they can access the route params.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dodekeract Ah, I think I see what you are saying now! It doesn't make sense to call match every time the view is rendered 👍.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer:

params.id
// or
(model, actions, { id }) -> id

over

model.router.params.id

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But, I was able to extract the match functionality outside onRender.

Copy link

@FlorianWendelborn FlorianWendelborn Mar 3, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jbucaran Your syntax proposal is compatible with mine. You can just call the render function with model, actions, model.router.params.

src/app.js Outdated
}

if (app.hooks) {
hooks.push(app.hooks)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of pushing objects into an array I would iterate through the objects and construct this data-structure:

var hooks = {
	onRender: [f1, f2, f3],
	onError: [f4]
}

This has the advantage that access is fast and easy. Basically it should be handled the same as subscriptions.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dodekeract Can you provide some pseudocode?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

var hooks = {
	onRender: []
}
function addToHooks(hook, name) {
	hooks[name].push(hook)
}
function handlePluginHooks (hooks) {
	for (var name in hooks) {
		addToHooks(hooks[name], name)
	}
}

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems more verbose. Specially if more hooks are added in the future. I'll see if there are any performance gains! 👍

Copy link

@FlorianWendelborn FlorianWendelborn Mar 3, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There will be performance gains. https://jsperf.com/for-in-or-for

1,400,835,640 vs 43,479 for me.

Copy link
Owner Author

@jorgebucaran jorgebucaran Mar 3, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you talking about for-in used with arrays?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, yes. Hard to keep track of 10 conversations at the same time.

src/app.js Outdated
}

function hook(name, fn) {
for (var key in hooks) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't use for in for array iteration. It's slow and potentially buggy.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

src/app.js Outdated

domContentLoaded(function () {
root = options.root || document.body.appendChild(document.createElement("div"))
for (var key in plugins) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't use for ... in ... for array iteration.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the other hand for loop is more verbose and we'd need to check if it's empty or not with .length. I'll experiment with for and see how that affects size/performance. 👍

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Size might be more, but for in is basically a hack when used on arrays.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just out of curiosity how come for ... in is being preferred over forEach or map

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lukejacksonn for in is used to loop through props of an object. This can be used to traverse an array too, but it's a "hack". The best practice to traverse arrays is using a regular for loop or forEach.

I've changed them all to use a regular for loop, so say bye to for in! :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎉

src/app.js Outdated
subs[key](model, actions, hooks.onError)
render(model, view)

for (var key in subscriptions) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for ... in

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

src/app.js Outdated
@@ -53,32 +52,72 @@ export default function (options) {

if (typeof action === "function") {
container[key] = function (data) {
hooks.onAction(name, data)
hook("onAction", function (fn) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you implement the data structure I suggested this would be

hooks.onAction.forEach(hook => hook(name, data))

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, this sounds good. Let me see how I can do that.

@@ -1,5 +1,5 @@
import h from "./h"
import app from "./app"
import router from "./router"
import Router from "./router"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this TitleCase? It's not a class.

Copy link
Owner Author

@jorgebucaran jorgebucaran Mar 3, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not a class.

I don't care about that convention. I think it sucks to limit myself to a convention for a construct I rarely use in JavaScript anyway. I don't use Classes/Prototypes.

Router is more distinctive. It's not a component, like in React, but they also use Router, which is familiar.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jbucaran the thing is that it implies that router has to be used like new Router().

Copy link
Owner Author

@jorgebucaran jorgebucaran Mar 3, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't use new or this; as an extension, neither does HyperApp. So, why should Router imply that?

Copy link

@FlorianWendelborn FlorianWendelborn Mar 3, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because it's TitleCase. For someone familiar with javascript (and not that much with hyperapp's source-code) that usually means it has to be instantiated.

Copy link
Owner Author

@jorgebucaran jorgebucaran Mar 3, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Plugins written in title case look better, just like Components.

app({ 
    plugins: [
        Router, 
        Logger, 
        TimeDebugger, 
        FusionGenerator, 
        FluxCapacitor
    ]
})

Copy link
Contributor

@lukejacksonn lukejacksonn Mar 3, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Router could be a plugin but that doesn't mean it has to be. If it is then it negates the R vs r issue. The awkward thing about the router is that it is not needed for all cases, but in reality it is needed for a lot of cases and if it is not needed (they only have one view/route) then hyperapp already has that covered with very little redundant bytes. Good frontend routers are hard to come by. I think this..

model.router = {
	matched: '/some/route/:id',
	url: '/some/route/34593458',
	params: {
		id: 34593458
	}
}

with an onrender onRender hook, feels like a very robust approach. I'd say for the sakes of argument.. abstract the router out, use it as an example of a really useful plugin (npm i hyperapp-router -S is a good practice for developers to get into anyway if we are encouraging a plugin ecosystem) and let people call it what they want.

Copy link
Owner Author

@jorgebucaran jorgebucaran Mar 3, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @lukejacksonn. Notice the correct name is onRender. Hooks are application level events so we use camelCase. DOM events are lowercase so we use oncreate, onupdate, onremove for lifecycle methods. Just for the record and in case someone finds this in the future.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For what it's worth, the convention in Hapi is to capitalize anything you 'require'. It makes it more clear which variables are from an external file, and I agree with @jbucaran that it looks nicer.

Copy link
Owner Author

@jorgebucaran jorgebucaran Mar 3, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good to know! Components are also capitalized in HyperApp, but they are not classes.

@jorgebucaran jorgebucaran added this to the 0.7.0 milestone Mar 3, 2017
Copy link

@FlorianWendelborn FlorianWendelborn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other than the for ... in ... it's good.

Since we use gzip I don't think there is any meaningful file-size difference in using the proper array iteration. There is a huge performance difference though.

src/app.js Outdated
})) {
throw err
var onError = function (error) {
for (var hookKey in hooks.onError) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for in 😢

src/app.js Outdated
hook("onAction", function (fn) {
fn(name, data)
})
for (var hookKey in hooks.onAction) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😢

src/app.js Outdated
hook("onUpdate", function (fn) {
fn(oldModel, model, data)
})
for (var hookKey in hooks.onUpdate) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😢

src/app.js Outdated
hook("onRender", function (fn) {
view = fn(model, view)
})
for (var i in hooks.onRender) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😢

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dodekeract Using for in here is the easiest way to avoid checking if onRender is undefined.

src/app.js Outdated

domContentLoaded(function () {
root = options.root || document.body.appendChild(document.createElement("div"))
for (var key in plugins) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just out of curiosity how come for ... in is being preferred over forEach or map

@jorgebucaran
Copy link
Owner Author

@dodekeract Thanks for the comments. I removed several instances of for..in in favor of a for loop, updated the hooks system as suggested and revamped the router.

@jorgebucaran
Copy link
Owner Author

jorgebucaran commented Mar 3, 2017

Hi @tzellman @danigb! Could you take a loot at the tests? I rewrote the entire spec, but even with 100% coverage maybe there are still features untested or missing cases?

[*.{diff,md}]
trim_trailing_whitespace = true

[*.json]
indent_style = space
indent_size = 2
Copy link
Contributor

@lukejacksonn lukejacksonn Mar 3, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❤️

@jorgebucaran jorgebucaran merged commit f81a6b5 into master Mar 4, 2017
@jorgebucaran jorgebucaran deleted the plugin-architecture branch March 5, 2017 10:02
jorgebucaran added a commit that referenced this pull request Jan 7, 2018
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants