jorgebucaran / superfine

Absolutely minimal view layer for building web interfaces
https://git.io/super
MIT License
1.56k stars 78 forks source link

Lifecycle Events #167

Closed elyobo closed 4 years ago

elyobo commented 4 years ago

4b3593b23cc49e367aa8d019af84105e2bd81c6f dropped support for lifecycle events without much fanfare, what's the rationale and the recommendation for implementing features that would otherwise have been implemented with lifecycle events (e.g. hyperapp had a thorough discussion about a similar change).

jorgebucaran commented 4 years ago

@elyobo I was just about to link you to https://github.com/jorgebucaran/hyperapp/issues/717. The rationale is the same as Hyperapp. As for recommendations, there's plenty of options in that issue.

Custom elements are a decent alternative to stateful components. Mutation events can help with particular use cases like detecting when an element leaves the DOM.

Hyperapp has effects and subscriptions to help you encapsulate side effects like grabbing an element by its ID and give it focus or subscribing to event listeners. In Superfine, you'll come up with your own abstractions.

Let me know if you're looking for more help.

elyobo commented 4 years ago

Thanks for the quick response @jorgebucaran. Reviewing the options, it looks like it's possible to avoid lifecycle events but at the cost of significant complexity - removing lifecycle events has made trivial things relatively difficult and much more messy.

I think we'll pin ^6.0.0 for now and see how we go.

jorgebucaran commented 4 years ago

@elyobo I didn't remove lifecycle events to make Superfine easier to maintain. I got rid of them because they're not the right abstraction to writing pure apps. Superfine (like Hyperapp) wants you to write purely functional apps, and lifecycle events were impeding that.

No one wants to write a bunch of imperative DOM code: getElementById, getBoundingClientRect, mutation observers, addEventListener, etc., either. That's why you're going to create abstractions to run side effects, subscribe to events, handle navigation, etc. Superfine is only the view layer; everything else is up to you.

Now, that could be a problem for people coming from Superfine 6, so I understand your decision.

elyobo commented 4 years ago

Thanks Jorge.

We don't think that's how we want to write apps right now, but it's fine that superfine is going another direction. Maybe we'll come around to the same conclusions later though, as you have done over the course of your work on hyperapp and superfine :)

Thanks for your help and work on these tools.

On Fri, 12 Jul 2019, 20:55 Jorge Bucaran, notifications@github.com wrote:

@elyobo https://github.com/elyobo I didn't remove lifecycle events to make Superfine easier to maintain. I got rid of them because they're not the right abstraction to writing pure apps. Superfine (like Hyperapp) wants you to write purely functional apps, and lifecycle events were impeding that.

No one wants to write a bunch of imperative DOM code: getElementById, getBoundingClientRect, mutation observers, addEventListener, etc., either. That's why you're going to create abstractions to run side effects, subscribe to events, handle navigation, etc. Superfine is only the view layer; everything else is up to you.

Now, that could be a problem for people coming from Superfine 6, so I understand your decision.

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub https://github.com/jorgebucaran/superfine/issues/167?email_source=notifications&email_token=AADDR7TJZ4O4B26ERWW3R43P7BPLHA5CNFSM4IAV22XKYY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGODZZNYVA#issuecomment-510843988, or mute the thread https://github.com/notifications/unsubscribe-auth/AADDR7UE2XU5O3VYSBQE4X3P7BPLHANCNFSM4IAV22XA .

mindplay-dk commented 4 years ago

No one wants to write a bunch of imperative DOM code: getElementById, getBoundingClientRect, mutation observers, addEventListener, etc., either. That's why you're going to create abstractions to run side effects, subscribe to events, handle navigation, etc. Superfine is only the view layer; everything else is up to you.

How?

There are no hooks or callbacks of any sort - once you call out to the render function, it doesn't cease control until everything is done.

So the only option right now seems to be an entire layer over both h and patch and selectively delegating control to the library - but that would seem to imply this layer is going to manage the both the virtual DOM and the life-cycle of virtual elements itself. It even has to know about the DOM elements, or at least the roots, to manually take control of events and the scope of updates, life-cycle related effects, etc. - at that point, aren't you just reimplementing everything Superfine does, just in a slightly different way?

If I'm calling out to Superfine effectively to render individual elements, that seems to imply I'm reimplementing the difficult part of reconciliation (diffing children) and Superfine really ends up being responsible for just the easy part, applying attributes.

For example, this library still wouldn't fint into HyperApp, would it? I don't think it provides the granular control that would be required to implement it in a library like HyperApp.

To make it viable as a library, I think you need to provide some sort of delegation of control?

And maybe element-level life-cycle hooks were the wrong approach - I haven't managed to build anything useful on top of it myself, and I've seen others try and getting less than useful results.

How about exposing hooks to the internal "checkpoints" in the reconciliation process instead?

Like, instead of element-level life-cycle hooks, expose the internal hooks, e.g. createNode, patchNode and patchProperty, e.g. as object properties - so that a library could create a custom render-function that overrides these functions as needed, like a kind of "middleware" that ties into every aspect of the managed life-cycle?

Just one possible approach. I'll be honest, I haven't considered any of this in great detail. But I think, if Superfine is going to be useful as more than a micro-framework, as a library in something bigger, it needs to provide some degree of control to the layer above it.

Otherwise, I don't see how building any of the abstractions you're suggesting is really possible at this point?

jorgebucaran commented 4 years ago

@mindplay-dk You can use Superfine to implement a Hyperapp-like clone.

const start = ({ init, view, update, node }, state) => {
  const setState = newState => {
    state = newState
    node = patch(node, view(app))
  }
  const app = {
    dispatch: name => setState(update(state, name)),
    getState: () => state
  }
  setState(init())
}

start({
  init: () => 0,
  view: app =>
    h("div", {}, [
      h("h1", {}, app.getState()),
      h("button", { onclick: () => app.dispatch("DOWN") }, "-"),
      h("button", { onclick: () => app.dispatch("UP") }, "+")
    ]),
  update: (state, msg) =>
    msg === "DOWN" ? state - 1 : msg === "UP" ? state + 1 : 0,
  node: document.getElementById("app")
})
elyobo commented 4 years ago

It's not my intention to rehash any discussions about the pros and cons of lifecycle functions here, as I think that has been done thoroughly elsewhere.

However, if you don't mind pointing me in the right direction for when we do move on from v6, it's not at all clear to me how I would implement something that reacts to the creation of a new DOM element (so as to do further work on it) in a sane way without an oncreate style lifecycle hook in superfine or hyperapp - it's generally going to be possible with mutation observers, but that like a very awkward way to do it (encapsulation break, and some additional overhead to inspect all changes vs just hearing about the ones you care about via oncreate).

The effects hyperapp issue mentions "setting or removing the focus on a DOM element", which would require the element itself (such as previously provided by oncreate) but there was no given example for anything that requires making a change to a DOM element after it was created.

mindplay-dk commented 4 years ago

You can use Superfine to implement a Hyperapp-like clone.

I know, I've seen the examples.

But this can't address e.g. render boundaries of components - you will re-render the entire app, including all components, for any state change.

And yes, you might be able to pick apart the application, keep references to DOM elements and manually patch() when and where needed - but that's a pretty fragile (and messy) approach, probably the sort of thing we'd like to improve on with an abstraction layer over the library, and, as you said, "No one wants to write a bunch of imperative DOM code".

This also doesn't really let you implement effects - if, by effects, we mean side-effects of (at least) updating or creating/removing elements or components. Even basic effects, such as focusing and selecting the contents of a text-input are no longer possible since the removal of element-level life-cycle events - that's how we created effects before, and there's currently no alternative.

Of course, since rendering is synchronous, you can have "global" effects, e.g. by applying them after a call to patch() - but you can no longer have any "local" effects, e.g. effects per component instance, since there are no hooks, and that information doesn't really exist anywhere.

This kind of circles back to our previous discussion about early vs late expansion of functional components - by immediately invoking functional components, reconciliation has no awareness of functional components at all. They're treated as just functions - not as components, or at least not in the sense that a (V)DOM element is a component represented by an instance with a managed life-cycle.

Again, I know I'm not proposing anything very concrete or actionable here - just trying to formulate what I see as the roadblock to leveraging this package as a library. I know there's a few interesting things you can do with this library, just that it's role at this point is essentially limited to "render everything" - with no real means or options for leveraging and expanding upon the reconciliation process and element life-cycle, which is currently treated more as an implementation detail than a feature.

And maybe that's "by design", but, returning to the original issue of this thread:

implementing features that would otherwise have been implemented with lifecycle events

Building abstractions for features that relied on lifecycle events isn't really possible at this point - and there's no really practical way to build such features, short of "a bunch of imperative DOM code".

I feel like this library has a ton of untapped potential. 🤷‍♂️

jorgebucaran commented 4 years ago

@elyobo However, if you don't mind pointing me in the right direction for when we do move on from v6, it's not at all clear to me how I would implement something that reacts to the creation of a new DOM element...

I'm definitely happy to give directions, but I prefer concrete questions. For example, how do I randomize a multidimensional grid of numbers using FLIP animations? Here's a possible implementation and a side effects manager based on the same Hyperapp-like clone I shared in my last comment.

src/startapp.js

export const start = ({ state, view, update, node }) => {
  const app = {
    dispatch: msg => setState(update(state, msg)),
    get state() {
      return state
    }
  }

  const setState = changes => {
    if (Array.isArray(changes)) {
      state = changes[0]
      changes[1](app.dispatch)
    } else {
      state = changes
    }
    node = patch(node, view(app))
  }

  setState(state)
}

src/utils/shuffle.js

export const shuffle = items =>
  items
    .map(a => [Math.random(), a])
    .sort((a, b) => a[0] - b[0])
    .map(a => a[1])

src/effects/flip.js

export const Flip = selector => dispatch => {
  const flip = new FLIP.group(
    [].map.call(document.querySelectorAll(selector), element => ({
      element,
      duration: 500
    }))
  )
  flip.first()
  requestAnimationFrame(() => {
    flip.last()
    flip.invert()
    flip.play()
  })
}

app.js

import { h, patch } from "superfine"
import { start } from "./src/startapp.js"
import { shuffle } from "./utils/shuffle"
import { Flip } from "./src/effects/flip"

start({
  state: {
    selectedId: 0,
    items: shuffle(
      [...Array(81).keys()].map(i => ({ id: i, number: (i % 9) + 1 }))
    )
  },
  view: ({ state, dispatch }) =>
    h("main", {}, [
      h("span", {}, `Cell: ${state.items[state.selectedId].number} `),
      h("button", { onclick: () => dispatch({ type: "SHUFFLE" }) }, "Shuffle"),
      h(
        "div",
        { class: "container" },
        state.items.map((item, i) =>
          h(
            "div",
            {
              class: "cell",
              key: item.id,
              onclick: () => dispatch({ type: "SELECT", number: i }),
              style: i === state.selectedId ? "background-color: lime" : ""
            },
            item.number
          )
        )
      )
    ]),
  update: (state, msg) =>
    msg.type === "SELECT"
      ? { ...state, selectedId: msg.number }
      : msg.type === "SHUFFLE"
      ? [
          {
            ...state,
            items: shuffle(state.items)
          },
          Flip(".cell")
        ]
      : state,
  node: document.getElementById("app")
})

https://codepen.io/anon/pen/jjozXz

jorgebucaran commented 4 years ago

@mindplay-dk ...you will re-render the entire app, including all components, for any state change.

In Hyperapp, this problem is solved using Lazy. In Superfine you could roll your own Lazy, or we could make it built-in like in Hyperapp. I'd like to see how far we can go out of the box with a more DIY approach, though.

Even basic effects, such as focusing and selecting the contents of a text-input are no longer possible since the removal of element-level life-cycle events - that's how we created effects before, and there's currently no alternative.

Yes, you can. It's just different. https://codepen.io/anon/pen/PrvajL

import { h, patch } from "https://unpkg.com/superfine"

start({
  state: {},
  view: ({ state, dispatch }) =>
    h("main", {}, [
      h("button", { 
        onclick: () => dispatch({ type: "FOCUS", selector: "input" })
      }, "Give Focus"),
      h("input", { id: "input", type: "text" })
    ]),
  update: (state, msg) =>
    msg.type === "FOCUS"
      ? [state, Focus(msg.selector)]
      : state,
  node: document.getElementById("app")
})

Building abstractions for features that relied on lifecycle events isn't really possible at this point - and there's no really practical way to build such features, short of "a bunch of imperative DOM code".

Nonsense. My examples show you a possible way (not necessarily the way, though). You are welcome to think outside your imperative, class-oriented/component-based box and try my gate-way drug to functional psychedelia... or stick to whatever already works best for you. It's up to you!

elyobo commented 4 years ago

Thanks @jorgebucaran, I had seen the similar FLIP example on the effects issue I linked to, but it doesn't deal with accessing the element that has just been created as per oncreate - it uses onclick, as does your focus example.

As a concrete example, my colleague was looking to implement a simple app using superfine as the view layer, but also using Material Components Web with its ripple effect, which requires making a single call on the relevant DOM element to wire it up. With oncreate this is simple, but I don't see a workaround without mutation observers without it - and mutation observers seem like a bad workaround!

Another example in the same vein would be to focus an element on creation rather than on click; HTML autofocus would work on page load, but not for dynamically added elements.

I don't have a problem with the effect / action based approach for onclick etc handling and I like the isolation of side effects (my own approach has been to pass in the side effect causing things, e.g. http clients, setTimeout, Math.random, so that they can be mocked for testing as needed, but actions/effects are cleaner). Is there no way to support similar side effect isolation for lifecycle events? Adding them back in would permit an effect based approach, but would not enforce it...

jorgebucaran commented 4 years ago

People overestimate lifecycle events. Why not think of your app as a series of state transitions that can involve one or more side effects? Effects encapsulate not just everything async, but also imperative API calls, like giving focus to elements or applying a visual ripple effect to your material design buttons.

@elyobo As a concrete example, my colleague was looking to implement a simple app using superfine as the view layer, but also using Material Components Web with its ripple effect...

Okay, here's a really simple example of that. Let me know if you had something more concrete in mind. We can optimize our discussion by sharing example requests that I can respond to with solutions, and I'm usually better at that (as opposed to having long abstract discussions).

src/html/md

const mdbtn = (props, children) =>
  h("button", { class: "mdc-button mdc-button--raised", ...props }, children)

const mdheadline = (props, children) =>
  h("h1", { class: "mdc-typography--headline1", ...props }, children)

src/effects/materialize.js

Here's a straightforward way to apply the ripple effect to the document's mdc-buttons using an effect.

// I'd prefer to import `ripple` as an ES module, but I don't know how to do that.

// import { ripple } from "material-design"

const Materialize = dispatch => {
  requestAnimationFrame(() => {
    Array.from(document.querySelectorAll(".mdc-button")).map(button =>
      mdc.ripple.MDCRipple.attachTo(button)
    )
  })
}

src/app.js

import { h, patch } from "https://unpkg.com/superfine"
import { startapp } from "./src/startapp"
import { mdbtn, mdheadline } from "./src/html/md"
import { Materialize } from "./src/effects/materialize"

start({
  state: [0, Materialize],
  view: ({ state, dispatch }) =>
    h("main", {}, [
      mdheadline({}, state),
      mdbtn({ onclick: () => dispatch({ type: "DOWN" }) }, "-"),
      mdbtn({ onclick: () => dispatch({ type: "UP" }) }, "+")
    ]),
  update: (state, msg) =>
    msg.type === "UP" ? state + 1 : msg.type === "DOWN" ? state - 1 : state,
  node: document.getElementById("app")
})

https://codepen.io/anon/pen/Wqqbdg

jorgebucaran commented 4 years ago

@elyobo Is there no way to support similar side effect isolation for lifecycle events? Adding them back in would permit an effect based approach, but would not enforce it...

Well, maybe, but just because we could, doesn't mean we should. I find it easier to reason about my app in terms of a model and state transitions rather than lifecycle hooks. I don't have enough evidence that adding a feature similar to lifecycle events back would improve Superfine. On the contrary, for writing purely functional apps, I find that we're better off without the lifecycle (or that I don't need it).

I'm not sold on the effect manager I showed here either (this is just the minimum viable implementation), but I recommend encapsulating side effects one way or another! :)

elyobo commented 4 years ago

Thanks @jorgebucaran, that's about as concrete as I can be :D

Your example works for the situation where elements are not dynamically added (although the encapsulation break does require splitting the button logic in two - in the button itself, and the addition of Materialize to the initial state - and that split is potentially error prone, as the relationship is unclear and easy to miss or edit in error down the road).

How would you handle dynamically adding these rippled buttons? You could have whatever causes the state transition (e.g. onclick etc) also retrigger Materialize as well, but that again requires a split of the logic - the thing that triggers the state transition needs to know what the effect of that will be (e.g. that a button that has not already been initialised will be shown that was not before) in order to know that Materialize must be triggered. Is there a better way?

I find it easier to reason about my app in terms of a model and state transitions rather than lifecycle hooks.

I agree (although I'm not used to writing things that way), but I'm not yet sure that "lifecycle hooks" necessarily live outside of that. If they were limited to causing effects, oncreate and ondestroy not break the model + state transitions view of the app, as they would just cause state transitions (which, as with any other, might have side effects).

jorgebucaran commented 4 years ago

@elyobo The Materialize effect is not limited to buttons. And rather than using effects, we could introduce a subscription manager into our startapp architecture. We'd need a general purpose subscription to observe a DOM subtree. The subscription would send a message to our update function whenever a new element enters the tree, and we can dispatch the necessary effects to Materialize it.

But I see what you mean. Buttons that are MDC'ed out of the box are more fun to work with.

Let's step back for a moment and revisit the deeper issue: arbitrary JavaScript messing with the DOM. What could possibly go wrong? It can break the VDOM abstraction. This is one of the reasons why lifecycle events are gone. Superfine can't guarantee the DOM won't go mad if you modify the inner HTML tree of a node or touch props you shouldn't be touching.

So, let me suggest a couple of alternatives:

  1. Re-implement the ripple effect with pure CSS (not that bad).
  2. Use custom elements!

Here's a simple demo using custom elements.

src/my-custom-elements.js

This is the least amount of code that works, but it even goes to show how to use Superfine to render the custom element inner content. Extending native HTML elements would be easier, but support is still shaky so I went with the more mediocre approach.

import { MDCRipple } from "@material/ripple"
import { h, patch } from "superfine"

customElements.define(
  "ripple-button",
  class extends HTMLElement {
    constructor() {
      super()
    }
    connectedCallback() {
      MDCRipple.attachTo(
        patch(
          this.childNodes[0],
          h("button", { class: "mdc-button mdc-button--raised" }, this.textContent)
        )
      )
    }
  }
)

src/app.js

Buttons are added dynamically based on the value of the counter. Just for fun.

import "./my-custom-elements"
import { h, patch } from "superfine"

const view = state =>
  h("main", {}, [
    h("h1", { class: "mdc-typography--headline1" }, state),
    Array.from({ length: (state % 3) + 1 }).map(() =>
      h("ripple-button", { onclick: () => patch(node, view(state + 1)) }, "+")
    )
  ])

const node = patch(document.getElementById("app"), view(0))

https://codepen.io/anon/pen/zVVjmY

elyobo commented 4 years ago

But I see what you mean. Buttons that are MDC'ed out of the box are more fun to work with.

It's mainly not that (although it is also that!), but that splitting things up makes it harder to understand and maintain, and easier to introduce accidental bugs during maintenance later.

Let's step back for a moment and revisit the deeper issue: arbitrary JavaScript messing with the DOM. What could possibly go wrong? It can break the VDOM abstraction. This is one of the reasons why lifecycle events are gone. Superfine can't guarantee the DOM won't go mad if you modify the inner HTML tree of a node or touch props you shouldn't be touching.

I see the reasoning here, but I think that the same argument could be made for onclick etc as well. I don't think superfine will be dropping those, because they're too useful, but I'm not sure what the deciding factor is that means no to lifecycle events, but yes to other ones! And even then, superfine is not the whole app and so other code can still mess with the DOM (which there's probably nothing we can do about, and is what the side effects that are pushed to the edge end up relying on anyway).

Re-implement the ripple effect with pure CSS (not that bad).

MDC actually has fallback CSS animations anyway :) However it's a useful simple example for the "I need to do something with a DOM element when it's created" problem (and the CSS version is nicer!).

Use custom elements!

This is perfect - because it allows a backdoor to a lifecycle event using the browser's implementation of one instead! Arbitrary code is executed on DOM element creation, just as if oncreate was still supported. It's efficient and doesn't split up the logic for dealing with the button, but it reintroduces the problems that you have with the lifecycle events (can mess with DOM, breaks model + state transitions view of the app).

I really appreciate the level of detail that you've put in to your responses here (and the use of complete working code is great for understanding) and I'm more convinced than I was about the general approach to isolating side effects that you've taken with hyperapp and superfine. I'm still not convinced that a side effect isolating approach to lifecycle events would be worse than the workarounds required to deal with its absence but can appreciate the desire not to support them - if a developer resorts to these workarounds, then any problems are clearly on them, as they're not a feature of superfine itself, and not having that feature discourages their use unless they're really needed.

rather than using effects, we could introduce a subscription manager into our startapp architecture. We'd need a general purpose subscription to observe a DOM subtree.

If you aren't tired of this issue by now, would you mind elaborating on this? I don't see how it would be possible (except via mutation observers - if that's what you were thinking no need to spend any time demonstrating).

jorgebucaran commented 4 years ago

@elyobo ... but that splitting things up makes it harder to understand and maintain, and easier to introduce accidental bugs during maintenance later.

I don't think we have enough evidence to make a lot of assumptions here. What if I want my app "skin" layer to be customizable. I want to choose between Materialize, Funkify, and Minimize. But I agree it's more convenient when things just work right out of the box; no effects, no nothing.

I see the reasoning here, but I think that the same argument could be made for onclick, etc. as well.

Yes, it could. I see your point. But where do we draw the line? If we go on this slippery slope, next thing you know we'll implement a custom event system too. And at that point, we might as well turn to Hyperapp. It's clear that lifecycle events were used to run imperative code on elements; real DOM events weren't (generally). Removing actual events would be a big deal.

...but it reintroduces the problems that you have with the lifecycle events (can mess with DOM, breaks model + state transitions view of the app).

No, no, that will never happen. I encourage you to reconsider this. Custom elements are true components. Unlike "Superfine components" and "React components" they'll work with any framework today or in the future. When Superfine renders your custom elements, it doesn't know they're "custom elements". They work like any other DOM element; div, button, etc. For the internal implementation, I chose Superfine, but I could've gone with React, Vue, or Angular. It doesn't matter. And if you want real and absolute isolation, you can even use shadow DOM. Extending native components is even simpler, look into it too!

I'm still not convinced that a side effect isolating approach to lifecycle events would be worse than the workarounds...

I'm not "completely convinced" either. But I see a lot of potential in getting rid of them and exploring alternatives like custom elements.

If you aren't tired of this issue by now, would you mind elaborating on this?

The implementation of the subscription would use mutation observers, but that's not important. You don't generally look under the hood. How Superfine works is an implementation detail. Someone implements the subscription once, and you just use it like you use any other function, component, effect, etc., in your app. Subscriptions can be a bit mind bending at first since the concept is still new. Please see https://github.com/jorgebucaran/hyperapp/issues/752 for a summary, background, and examples in Hyperapp.

elyobo commented 4 years ago

What if I want my app "skin" layer to be customizable. I want to choose between Materialize, Funkify, and Minimize.

I think that the additional complexity is a cost of adding that feature; it doesn't make the split less error prone, but it changes the cost/benefit calculations such that it might be worth it (and you could argue the same about splitting it up for other reasons, e.g. to keep things pure).

But where do we draw the line?

Yeah, it's tricky. It has to be drawn somewhere and, in terms of how lifecycle vs real DOM events were actually used, where it has been drawn does make sense.

...but it reintroduces the problems that you have with the lifecycle events (can mess with DOM, breaks model + state transitions view of the app).

No, no, that will never happen.

I'm not sure we're talking about the same thing here. I just mean that (aside from anything else) literally the custom elements give you the ability to run custom code at the point in time that the DOM element is created - which is exactly the functionality of the oncreate lifecycle event. And because it allows that, it reintroduces all those side effects which the removal of the lifecycle events is intended to avoid.

The implementation of the subscription would use mutation observers, but that's not important. You don't generally look under the hood.

I do if I'm implementing the stuff under the hood ;)

So, thank you for all of this, it has been a great really informative discussion for me and is much appreciated.

jorgebucaran commented 4 years ago

@elyobo ...run custom code at the point in time that the DOM element is created - which is exactly the functionality of the oncreate lifecycle event.

This is an oversimplification.

And because it allows that, it reintroduces all those side effects which the removal of the lifecycle events is intended to avoid.

No, what I meant by "arbitrary JavaScript messing with the DOM" and "breaking the VDOM abstraction" is, for example, setting the innerHTML property of a node or inserting a foreign DOM tree in a node created by Superfine. Now you need to make sure not to touch that node or its children. Maybe it is an unchanging node so it doesn't matter, but it's not so hard to inadvertently break Superfine (make it patch over unknown DOM). After all, Superfine is a blind robot. Custom elements won't break it, though.

I do if I'm implementing the stuff under the hood ;)

Ah, but you might not have to implement anything yourself. The subscription manager and DOM observer subscription are (they don't exist yet) general utilities you'd import right into your app. Like, I don't know, lodash. They're not specific to your app. In Hyperapp we offer this stuff via @hyperapp/* scoped packages. Superfine has none of these things, so you'd have to build them, but you do realize we're talking about different things? My point is, you don't need to write a subscription for every app, just import one.

elyobo commented 4 years ago

No, what I meant by "arbitrary JavaScript messing with the DOM" and "breaking the VDOM abstraction" is, for example, setting the innerHTML property of a node or inserting a foreign DOM tree in a node created by Superfine.

I'm still not seeing the distinction you're making between oncreate and the custom elements. It seems like I can do anything in the connectedCallback, including "setting the innerHTML property of a node or inserting a foreign DOM tree in a node created by Superfine" - I'm not restricted to only acting on the DOM of my own custom element (which would probably be safe). That's what I mean when I say that it reintroduces all those side effects which the removal of the lifecycle events is intended to avoid. Am I missing something here?

I do if I'm implementing the stuff under the hood ;)

Ah, but you might not have to implement anything yourself. you do realize we're talking about different things you don't need to write a subscription for every app, just import one

I realise that a general subscription manager could be separate to my particular app, and reused in others, but as it stands right now I'd be writing both if I wanted to because there isn't one I can take off the shelf at the moment as far as I know. And I'd likely peek under the hood occasionally even if I didn't write them myself (as I have done with both superfine and hyperapp)!

jorgebucaran commented 4 years ago

Hmm, maybe we're missing some key information here. Your custom element can be implemented in any way you want. Vanilla JavaScript, Superfine, Hyperapp, React, and so on. Say you choose Superfine. It'll be like writing a Superfine app, but the custom element is its own mini-app and keeps its own state apart from your app.

...but as it stands right now I'd be writing both if I wanted to because there isn't one I can take off the shelf at the moment as far as I know.

Correct. 😄

elyobo commented 4 years ago

Hmm, maybe we're missing some key information here.

Maybe, because I don't think we're quite on the same page here yet :)

How you implement the custom element internally doesn't matter for the point that I'm trying to make, just that you can run code when the element is created and added to the DOM (amongst other things) - you must do so in order to create the DOM for the element, but you can also do other things. Custom HTML Elements have lifecycle events and therefore their use reintroduces all those side effects which the removal of the lifecycle events from superfine is intended to avoid.

As an example https://codepen.io/elyobo/pen/qzewWg has a custom element modify the DOM outside itself when it's connected. Using custom elements has the same side effect consequence that lifecycle events within superfine does - you can use them both responsibly and you can also abuse both of them.

jorgebucaran commented 4 years ago

Custom HTML Elements have lifecycle events, and therefore their use reintroduces all those side effects which the removal of the lifecycle events from superfine is intended to avoid.

Nope. Your example shows how you can use a custom element to modify the outer DOM. You can do that without a custom element too; with lifecycle events or without them. That's not the kind of problem I was saying. That's not even a problem. Lifecycle events are an abstraction provided by the framework. They encourage people to modify elements imperatively. Using oncreate or onupdate to inject foreign HTML into the VDOM DOM can break Superfine in a future patch

Another thing I didn't mention (I think?) is how lifecycle events make testing the UI more difficult. They make the view impure, that's why.

elyobo commented 4 years ago

Your example shows how you can use a custom element to modify the outer DOM. You can do that without a custom element too; with lifecycle events or without them.

Of course you can, but I thought that your problem was that they could happen as part of a lifecycle event (and that changing the DOM underneath superfine's feet could cause it problems, which presumably it can).

That's not the kind of problem I was saying. That's not even a problem.

I've misunderstood the problem you're trying to address then :blush:

Lifecycle events are an abstraction provided by the framework. They encourage people to modify elements imperatively.

I'm not convinced that the distinction between a lifecycle event provided by the framework and a lifecycle callback provided by a custom html element that the framework causes to be executed more or less the same point in time is meaningful in terms of the side effects that can be caused (unless the framework provided version provides access to the internals of the framework in a way that cannot be accessed from within the custom element's code - which I don't think was the case with the old superfine lifecycle events?).

Using oncreate or onupdate to inject foreign HTML into the VDOM can make Superfine diff the DOM incorrectly during a future patch

OK, I think I see what I've missed here. I thought that your concern was that lifecycle events could modify the DOM, causing superfine to run amok, but your concern was that they could mess with the VDOM.

What's an example of a legacy (pre 6.x) lifecycle hook that would "inject foreign HTML into the VDOM"? I still can't see things that are possible with, for example, an old oncreate that would not also be possible within the lifecycle callbacks of a custom HTML element - if the former can call something that messes with the VDOM then how is it not possible for the latter to also do so? That might clear up my understanding of the functional differences mentioned in the previous paragraph.

Another thing I didn't mention (I think?) is how lifecycle events make testing the UI more difficult.

I don't think you did, but I agree, and a good reason to avoid lifecycle events if you can avoid them. You don't need to convince me that lifecycle events are undesirable though, I'm sold, I just think that sometimes they're better than the alternative - if I actually need to do something to an element that superfine has created then I have to do it somehow and all of the solutions (including lifecycle events) come with their own pros and cons.

jorgebucaran commented 4 years ago

I'm going to address the latter half of your comment because the first half is based on a wrong premise, partly caused by a typo I made. :bow:

@elyobo What's an example of a legacy (pre 6.x) lifecycle hook that would "inject foreign HTML into the VDOM"?

I meant "inject foreign HTML in the DOM". Whoops. My bad.

Let me explain diffing (briefly) first. There are three actors involved: the DOM, the (new) VDOM, and the old VDOM. During the first act (the first call to patch), the old VDOM is undefined, so Superfine creates a new DOM from scratch to match your (new) VDOM.

Later, it will diff the new and old VDOM to find out what changed and update the DOM efficiently (rather than re-creating it from scratch). Superfine keeps track of the old VDOM in your root node; that's why you only need to give patch the new VDOM at any given time.

Back to the main issue. What's wrong with lifecycle events?

Superfine can break (behave erratically or impredictably) when the VDOM doesn't match the DOM. That means it won't be able to reconcile the existing DOM (represented in the old VDOM), to match the new VDOM. As long as you use Superfine to handle a given DOM tree, you'll be fine, but if you change this tree "behind" Superfine's back, the internal VDOM representation will go out of sync with your modified DOM.

Sure. You can break Superfine without lifecycle events, but it's harder this way. Without lifecycle events, if you mutate the DOM, it must be by your own means, making it easy to spot any mistakes.

So, ideally, we'd build everything from scratch in Superfine in a neat, pure functional fashion. When this is not possible like when you want to run some arbitrary JS to add a cool ripple effect to a button, you can create a custom RippleButton. This is better than peppering Superfine's pure view layer with imperative code, therefore partly ruining the abstraction.

elyobo commented 4 years ago

OK, cool - thanks for the detailed explanation, but I think we were already on the same page then. I understand how changing the DOM and so causing a mismatch between the DOM and VDOM can cause problems.

So the removal of lifecycle events from superfine itself doesn't necessarily prevent any of the same type of problems, because the same side effects can all still be done (e.g. via custom html element lifecycle callbacks like in my example above), but it does discourage them - and anyone that jumps through the extra hoops will definitely be aware of the problems. The footguns still exist, but superfine has been disarmed :D

This is better than peppering Superfine's pure view layer with imperative code, therefore partly ruining the abstraction.

I'm not sure that burying the imperative code down in a custom html component that superfine causes to be executed is better (it's less obvious, being yet another layer deeper, and the use of custom html elements is going to make testing harder), but for the general purpose of avoiding unnecessary imperative code then yeah, this is better.

I don't really agree with the end result (I do still need to do some imperative things, and now they're harder to implement and test), but I can see where you're coming from and (like retaining DOM events even though they can also cause the same problems) you've got to draw a line somewhere.

Thanks for the thorough discussion.

jorgebucaran commented 4 years ago

I think you're missing the forest for the trees with custom elements. First, you can't break Superfine with custom elements (unless you do things that would break Superfine anyway, like modifying the outer DOM). Second, you can build and test custom elements separately. If they contain bugs, you'll immediately figure out if the error is in your custom elements or in your main app. And if designed carefully, your custom elements will act like generic atoms of functionality, not specific to your app. Custom elements are not at the same level as Superfine lifecycle events, not even close!

I'm not sure that burying the imperative code down in a custom html component that superfine causes to be executed is better.

It can be a mix of imperative and declarative code. It depends on what abstractions you've built to deal with effects and subscriptions. Startapp is a decent start. Imagine what someone with more focus can do.

I do still need to do some imperative things, and now they're harder to implement and test.

That's completely false. Testing a declarative layer is so much easier than testing imperative code. In fact, simpler testing motivates Hyperapp eschewing lifecycle events and introducing effects and subscriptions. Same here.

I still have some imperative code I need to test. Well, that will be just as fun to maintain as it is with other frameworks, but at least the dirty bits are not entangled with your Superfine views.

elyobo commented 4 years ago

First, you can't break Superfine with custom elements (unless you do things that would break Superfine anyway, like modifying the outer DOM).

That's like saying you can't break superfine with lifecycle events (unless you do things that would break Superfine anyway, like modifying the outer DOM). The whole paragraph could replace "custom elements" with "lifecycle events" and still be correct :)

That's completely false. Testing a declarative layer is so much easier than testing imperative code.

But it's not declarative, because even though superfine may be, it's calling the web components which contain the imperative code.

but at least the dirty bits are not entangled with your Superfine views

But they are because superfine's instantiation of the custom html elements will trigger the declarative code to be run, just the same as the old lifecycle hooks do.

And if designed carefully, your custom elements will act like generic atoms of functionality

Indeed. And if designed carefully your lifecycle hooks would have too.

jorgebucaran commented 4 years ago

We already agree on some stuff. Let's see if we can agree on the important part now. 😉

@elyobo Indeed. And if designed carefully your lifecycle hooks would have too.

Nope. Lifecycle events are a non-standard (fabricated) layer between Superfine and the DOM. They don't provide encapsulation and by embedding imperative code in your view you make it impure and difficult to test. Period. Custom elements, on the other hand, provide encapsulation and more importantly do not make your view impure. They're separately tested, and how difficult it is to test them depends on how you implement them. Your app can treat them like other native elements, e.g., <p>. If you have trouble understanding this, I suggest you stop reading here and revisit encapsulation and custom elements, else, keep going.

@elyobo That's like saying you can't break superfine with lifecycle events (unless you do things that would break Superfine anyway, like modifying the outer DOM). The whole paragraph could replace "custom elements" with "lifecycle events" and still be correct :)

Aha, I see two serious bugs in your reasoning:

1. Misunderstanding what I mean by changing the DOM can break Superfine.

This point is a subtle one. Remember when I said: "Superfine can't guarantee the DOM won't go mad if you modify the inner HTML tree of a node or touch props you shouldn't be touching."? I wasn't talking about modifying the DOM irresponsibly!

What am I talking about? Well, there's a crucial difference between giving you canonical access to DOM elements via lifecycle events and someone deliberately breaking the DOM using DOM APIs. I need to apologize for the first one; the second is not my business. Because 6.0x Superfine provided lifecycle events, it was (partly) responsible when you messed up the DOM by using them. Now without lifecycle events, we can't blame Superfine when you go out of your way to break it. If I give you poison and then you drink it, I should take some of the blame, right? Even if you disagree with that, you'll accept that the authorities should at least bring me in for questioning. If you drink poison behind my back I hope to read about it in the news. That's one of the reasons I removed lifecycle events. Because they're poison!

Of course, I didn't eliminate the need to access the DOM now and then. That I couldn't do, but by limiting Superfine's scope, I am liberated and can focus on solving one problem exceptionally well: diff & patch.

Hmm, but is it absolutely impossible to integrate 6.0x-like lifecycle events into 7.0x's pure functional approach? I don't know. If you can figure this out, create another issue or (even better) send me a PR and I promise I'll take a good look.

2. Treating Superfine 6.0x lifecycle events like a mini custom elements layer.

I see what you mean, some of the things we can do with custom elements overlap with Superfine 6.0x lifecycle events, but they're capabilities are way beyond that. Lifecycle events were a Superfine made up thing (I made them up). Custom elements work natively. They're a platform thing. Comparing Superfine 6.0x to custom elements doesn't do justice to custom elements.

We use custom elements to keep a DOM tree, style, and behavior hidden and separate from other code on the page, so we (Superfine) can't touch it. Think of an <input type="date">. It consists of a text field, button, popup window, more buttons, etc., but they're out of your reach. Can't touch them. Can't break the hidden DOM structure. Custom elements give you true, cross-framework, native, efficient DOM encapsulation. And what's more, I don't need to do a thing to support them!

@elyobo But it's not declarative, because even though superfine may be, it's calling the web components which contain the imperative code.

That's like saying that Superfine is not declarative because mutation does occur behind the scenes or that Elm is not functional because the runtime is written in JavaScript. That's not how a computer scientist concludes if a piece of code is declarative or not. Superfine/Hyperapp/Elm expose are pure interface; therefore, your programs are pure too (genuinely pure in the case of Elm since the compiler enforces purity). The library, framework, runtime, etc., just like a custom element internals are an implementation detail. The user doesn't need to know how it gets done, so long as it gets done (quote by Robert Harvey).

Let me try again. I'd use Superfine (or Hyperapp) to implement a custom element, but I could choose 100% vanilla JavaScript, var, let, mutation as well; it doesn't matter. But why? We think of <button>, <select>, and <h1> as indivisible units. We know they're not atoms, and that they hide extraordinary complexity, but we safely ignore all that while using them. Superfine only sees a plain JavaScript object, <button> or <fancy-button>, they're represented using the same { name, props, children, key } object.

elyobo commented 4 years ago

I think we need to compile this and publish it as a novel :D

I'm going to jump to the end and maybe work backwards.

That's like saying that Superfine is not declarative because mutation does occur behind the scenes or that Elm is not functional because the runtime is written in JavaScript.

I'm not trying to say that superfine is not declarative, I'm saying that my view layer is not declarative if it uses custom html elements. My view layer is larger than just superfine once I'm doing imperative things that superfine is triggering. While superfine doesn't have side effects, running it can still have side effects...

Superfine/Hyperapp/Elm expose are pure interface; therefore, your programs are pure too (genuinely pure in the case of Elm since the compiler enforces purity).

...and so I don't think that this is the case, because I can still sneak in impure stuff that superfine calls. I don't see a difference in purity between giving superfine a function that it calls (lifecycle event), and telling it to create a custom html element that then calls that same function. In the same way that superfine could previously expose an impure interface but actually be pure in practice (by not using any of the lifecycle events), it can also expose a pure interface but actually be impure in practice (because it can still end up lifecycle events wrapped up in the html components).

I mean I can literally add back an oncreate to my custom component and superfine will wire it up for my component to run - https://codepen.io/elyobo/pen/gVOMOK (I think this is great, btw, it does show off how useful these components are).

I see what you mean, some of the things we can do with custom elements overlap with Superfine 6.0x lifecycle events, but they're capabilities are way beyond that.

Definitely, I'm not claiming that custom elements are equivalent to lifecycle events, they're totally different! Superfine's pre 7.0.0 lifecycle events are (largely? entirely?) a subset of custom html elements' capabilities. Again, I don't think much in the way of misunderstanding or disagreement here.

We know they're not atoms, and that they hide extraordinary complexity, but we safely ignore all that while using them.

Except for maybe this; true enough for the standard ones, but the ones that I provide may not be so responsible.

Well, there's a crucial difference between giving you canonical access to DOM elements via lifecycle events and someone deliberately breaking the DOM using DOM APIs.

Agreed, I don't think there's any misunderstanding or disagreement here either - this is what I was getting at earlier when I said that

The footguns still exist, but superfine has been disarmed :D

So I'm not sure we actually have any disagreement about anything except semantics, really.

I might have decided differently about removing lifecycle events, but appreciate your reasoning about why you've done so (and that there is a difference, if non functional, between providing an API to do it and not doing so, even if it can still be done), and I'm happy that I can add back largely equivalent functionality in via custom HTML components if I need to.

jorgebucaran commented 4 years ago

There are a few comments I'd like to address, as I feel we're close to an agreement (or at least mutual understanding) and I don't want to miss the chance. So, here I go again, more sharply this time.

@elyobo I'm not trying to say that superfine is not declarative, I'm saying that my view layer is not declarative if it uses custom html elements. My view layer is larger than just superfine once I'm doing imperative things that superfine is triggering.

@elyobo Except for maybe this; true enough for the standard ones, but the ones that I provide may not be so responsible.

HTML is declarative. The internal implementation isn't.

For all means and purposes<button> and<fancy-button> belong in the same category. It doesn't matter if you call createElement("fancy-button") yourself, or leave it to Superfine.

Do we know how <select> is implemented? The answer is: who cares? No, I'm not saying we can't be curious and look under the hood; we can and we should. But <select> is not implemented in a functional language. Its implementation relies on variables, local state, etc. It could be implemented in Haskell instead. Or Scala. Or assembly. That wouldn't change anything I've said.

My HTML code is declarative. My Superfine (VDOM-based) app is declarative.

By using custom elements, I can keep the view layer 100% declarative. Custom elements are the way to run arbitrary code to create UI atoms that look & feel like native elements, without affecting the purity of your view.

@elyobo ...and so I don't think that this is the case, because I can still sneak in impure stuff that superfine calls.

Superfine is less strict that Hyperapp, not to mention Elm. Even with all the imposed restrictions, you can still shoot yourself in the foot. Superfine is a Hyperapp-spinoff essay in DIY after all.

@elyobo I don't see a difference in purity between giving superfine a function that it calls (lifecycle event), and telling it to create a custom html element that then calls that same function.

There's a difference between using lifecycle events to bastardize Superfine's DOM, and using custom elements to encapsulate arbitrary JavaScript. For example, by using ShadowDOM, we can render DOM without putting it in the main document DOM tree. So, the developer can't reach the element's internal DOM in the same way they would with nested elements, while the browser still can. 6.0x lifecycle events couldn't do that in their wildest dreams.

elyobo commented 4 years ago

Even with all the imposed restrictions, you can still shoot yourself in the foot.

I think this is really the key thing here. The removal of the lifecycle events has made it less likely that users will do this, but it's not possible to remove that functionality without major tradeoffs (e.g. you could block creation of custom HTML elements) - just like you could make it more pure by removing support for DOM events, but the lost functionality would be rather significant!

Whether superfine calls a function directly via a lifecycle event that it supports or whether superfine calls a function indirectly by asking the browser to create an element that then calls the function seems like an academic distinction in terms of purity/declarativeness - superfine might arguably be more pure than it was before, but that change hasn't changed the behaviour of the whole view layer.

Both of these were possible in 6.x, but the second remains possible in 7.x.

<button oncreate={breakThings} />
<x-button oncreate={breakThings} />

There's a difference between using lifecycle events to bastardize Superfine's DOM, and using custom elements to encapsulate arbitrary JavaScript

There is a difference, but not a meaningful one in this case. My issue isn't about outside things reaching in, it's about inside things reaching out (the shadow DOM protects the element from external manipulation, but nothing keeps the external DOM safe from the element's internal implementation).

Given a function breakThings which bastardises the superfine DOM, bastardising superfine's DOM through a superfine supported lifecycle event calling it, bastardising it through a superfine supported DOM event calling it, and bastardising it via superfine creating an element that you've created that then calls it are all examples of superfine running user provided code. I see no change in purity or declarativeness (declarativity? We need a new word here!), just additional hoops to jump through in the last case - which may well deter users that don't really need that functionality from doing it, so it isn't pointless, and we're really just disagreeing about semantics as I suggested earlier 🤷

jorgebucaran commented 4 years ago

A careful reader should be able to figure out how I would counter your new comments, without us going another round. And I'm sure we don't want to go another round at this point. 👋

...bastardises the superfine DOM, bastardising superfine's DOM through a superfine supported lifecycle event calling it, bastardising it through a superfine supported DOM event calling it, and bastardising...

Nice one. 😂

elyobo commented 4 years ago

Seems like a good point on which to end.

I'll edit my original question to include the key points for me (lifecycle events should be avoided if possible, but if necessary custom html elements can be used to provide them; still try to avoid side effects, e.g. using something along the lines of effects as shown in the hyperapp issue already linked) so that anyone that stumbles across this can save themselves reading this draft of our forthcoming novel 😁

On Sat, 27 Jul 2019, 07:11 Jorge Bucaran, notifications@github.com wrote:

A careful reader should be able to figure out how I would counter your new comment, without going another round. And I'm sure we don't want to go another round at this point. 👋

...bastardises the superfine DOM, bastardising superfine's DOM through a superfine supported lifecycle event calling it, bastardising it through a superfine supported DOM event calling it, and bastardising...

Nice one. 😂

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub https://github.com/jorgebucaran/superfine/issues/167?email_source=notifications&email_token=AADDR7WGB6IZY7CWV6UT5UTQBNSATA5CNFSM4IAV22XKYY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGOD25XEMQ#issuecomment-515600946, or mute the thread https://github.com/notifications/unsubscribe-auth/AADDR7WIGYNNNU4LBNYYDXTQBNSATANCNFSM4IAV22XA .

ysharplanguage commented 4 years ago

@jorgebucaran

On this:

rather than using effects, we could introduce a subscription manager into our startapp architecture. We'd need a general purpose subscription to observe a DOM subtree

My first hunch is this sort of ideas have been largely underinvestigated so far (eg, when compared to the number of frameworks either querying the dom, mutating it, or virtualizing it.

Indeed, we've seen so many selector-based libs to query that tree and/or mutate it imperatively from anywhere (sometimes from the middle of hundreds of LOCs of spaghetti code...) but we haven't seen many libs looking at that tree as a place (read: data structure) where data/event flows could leverage pub sub-driven decision making on the intended effects of user interactions. The closest we ever got, if I dare say, is just the primitive event bubbling ...

My second hunch is that custom elements are indeed a powerful orthogonal abstraction (ie, orthogonal to vdom diffing and can complement it nicely to stay away from direct, imperative mutation of the actual dom, in order to stick to purely functional, composable construction of dynamic views) to explore further.

Back to your above point I quoted, I'd investigate how to mix, eg :

Or maybe some new functional streams operators should be used to express those (subscriptions) directly instead, with some sort of syntax chaining?

Just my intuition on the alternative abstractions I'd explore (to combine) on the topic. No doubts I'm overlooking difficulties to flesh that out more concretely, though.

My .02