Crank: Are async and non-async generator components both needed?

Created on 18 Apr 2020  Â·  13Comments  Â·  Source: bikeshaving/crank

The current generator component API seems a bit complicated.

You have non-async components, where each yield pauses until the next render, and async components, where the pause mechanism is awaiting new props from this instead.

This difference in behaviour seems rather error prone. Are both mechanisms needed or would it be possible to use just one? For instance, could non-async generator components be eliminated?

Most helpful comment

I had similar thoughts early on in the design of Crank! I knew that async generator components mapped pretty well to React’s component lifecycle and didn’t really understand how sync generator components would work in the bigger picture, so I mostly neglected working on them. This changed when I finally got around to implementing TodoMVC because I realized that sync generator components are just as, if not more useful than async generator components. I know the execution models of sync and async generator components are very different but I think both have their place.

The design reasons I chose to keep sync generator components are as follows:

  1. Crank will never render asynchronously unless the user uses async components. This is a fundamental design decision and one which I will stick with to the grave. If we didn’t have sync generator components, we wouldn’t be able to write stateful components that rendered synchronously, which would be a big loss.
  2. The single most common refactor people make when they’re writing JSX components is to go from stateless to stateful components. I’ve tried to make this as painless as possible in Crank, you just move the return statement into a loop and rename the keyword to yield, and you can imagine a VSCode plugin that does this for you automatically. If we didn’t have sync generator components, jumping from stateless to stateful would also mean adding the async keyword, but more importantly it would mean that your components went from synchronous to asynchronous, which is a much bigger leap than if it could remain synchronous.

At the end of the day, supporting sync generator components is very little extra work over supporting async generator components, and gives developers the freedom to choose whether their components render synchronously or asynchronously. I understand that the differences between sync and async generator components can be confusing, and you might want to hold the opinion that all stateful components should be asynchronous. I don’t share that belief but I’d see where you were coming from.

All 13 comments

Problem I see with that is adding an unnecessary minor tick to the render for non-async generator returns.

I had similar thoughts early on in the design of Crank! I knew that async generator components mapped pretty well to React’s component lifecycle and didn’t really understand how sync generator components would work in the bigger picture, so I mostly neglected working on them. This changed when I finally got around to implementing TodoMVC because I realized that sync generator components are just as, if not more useful than async generator components. I know the execution models of sync and async generator components are very different but I think both have their place.

The design reasons I chose to keep sync generator components are as follows:

  1. Crank will never render asynchronously unless the user uses async components. This is a fundamental design decision and one which I will stick with to the grave. If we didn’t have sync generator components, we wouldn’t be able to write stateful components that rendered synchronously, which would be a big loss.
  2. The single most common refactor people make when they’re writing JSX components is to go from stateless to stateful components. I’ve tried to make this as painless as possible in Crank, you just move the return statement into a loop and rename the keyword to yield, and you can imagine a VSCode plugin that does this for you automatically. If we didn’t have sync generator components, jumping from stateless to stateful would also mean adding the async keyword, but more importantly it would mean that your components went from synchronous to asynchronous, which is a much bigger leap than if it could remain synchronous.

At the end of the day, supporting sync generator components is very little extra work over supporting async generator components, and gives developers the freedom to choose whether their components render synchronously or asynchronously. I understand that the differences between sync and async generator components can be confusing, and you might want to hold the opinion that all stateful components should be asynchronous. I don’t share that belief but I’d see where you were coming from.

Thanks for such a detailed response. Naturally, I haven't though through the problem in depth like you have, and your points make sense, especially about avoiding forcing asynchronicity on people.

So I don't hold an opinion on an alternative, I just find the apparent error-proneness concerning at first glance (more than I think the freedom given to developers is beneficial) and wonder how easy it would be to debug a mistake. But perhaps there is no other plausible API with the same expressiveness :)

The design reasons I chose to keep sync generator components are as follows:

  1. Crank will never render asynchronously unless the user uses async components. This is a fundamental design decision and one which I will stick with to the grave. If we didn’t have sync generator components, we wouldn’t be able to write stateful components that rendered synchronously, which would be a big loss.

I absolutely agree that it would be madness to throw away sync generator components. You simply can’t make a compromise-free virtual list using async rendering. Additionally, responding to screen resizing and device rotation is torture when rendering is async.

I don't understand why sync generators are mandatory since even the "tick" doesn't postpone a render (since async functions and generators wait a microtick).

I think supporting sync generators makes sense from a usability standpoint since I'd expect everything iterable or async iterable to work.

You simply can’t make a compromise-free virtual list using async rendering

responding to screen resizing and device rotation is torture when rendering is async

I'm ignorant to these issues. Could someone explain why are these bad to implement in async generator components?

@darsain it's not, old promise libraries and implementations didn't take care of microtick/macrotick semantics (or Job semantics).

Basically as a gross and somewhat incorrect oversimplification there are two types of tasks in the web:

  • Things that have to run _between_ events called microtasks or jobs. For example: mutation observer callbacks, process.nextTick in node (that ironically runs on the same tick), literally calling queueMicrotask and promise then callbacks and awaits. We'll get to that later.
  • Things that have to run _as_ events - called "tasks". For example - async event handlers, addEventListener callbacks, timers and other things.

Promises traditionally (giving the example from bluebird since it's the most popular and one I'm a core member of) use task semantics and not microtask semantics (with a trampoline but let's not get carried away) - though native promises and new implementations use microtask semantics.

This means with bluebird promises - if you have a resize listener or a scroll listener and you did not setScheduler then __you are not guaranteed that your code is called in a correct order between resize events__ For example let's look at the following code from 2015:

function once(el, event) {
  return new Promise((resolve, reject) => 
    const handler = function(e) { 
      resolve(e);
      el.removeEventListener(event, handler); 
    };
    el.addEventListener(event, handler);
  });
}
async function* fromEvent(el, event) {
   while(true) yield await once(el, event);
}

And let's iterate all scroll events:

async function* scrolls() {
  for await (const scroll of fromEvent(document, 'scroll')) {
    // react to scroll event
  }
}

With old promise implementations that used task semantics - we are not guaranteed the async iterator yields for every scroll event because two scrolls might be fired.

If you are on the other hand using native promises, setScheduler or using a MutationObserver to listen to the changes - you are guaranteed that because of microtick semantics your code always gets called "in the correct order" and before the next event is dispatched.

That is:

// this guarantee is relatively new, and why I think the claim about async generator component
// is made
setTimeout(() => {
  console.log("This is always third");
});
Promise.resolve().then(() => {
  console.log("This is always second");
});
console.log("This is always first");

You simply can’t make a compromise-free virtual list using async rendering

responding to screen resizing and device rotation is torture when rendering is async

I'm ignorant to these issues. Could someone explain why are these bad to implement in async generator components?

I'll answer about virtual lists first.

Because on both iOS and Android, the delegate methods for dequeuing a (new or recycled) cell in the native Virtual List implementation want to return a native view synchronously. If the renderer can't produce a view synchronously, then it is basically forced to provide a blank "hosting view" for the cell, and render into that asynchronously as soon as it can manage. This means that your Virtual List will initialise each cell as blank, and they'll have to perform layout – leading to jank – as soon as the element tree undergoes rendering.

Got to head off to lunch now; can come back with an explanation of layout changes later.

@shirakaba that's a good point - though why couldn't the binding just block for a microtask?

That is, assuming iOS (just to pick one) you'd have a UITableView that uses tableView(_:cellForRowAt:)
- couldn't the implementation just block (synchronously) while the async generator runs? Both platforms (iOS and Android at least) have threads and blocking IIUC.

@benjamingr Even if promises and the microtask queue represent the highest-priority/smallest unit of asynchrony, the microtask queue is first-in-first-out, and a microtask which was scheduled before you kicked off rendering would win, potentially causing race conditions. The fact that an element tree renders synchronously when it contains no async components is an incredibly useful invariant, and I’m extremely hesitant to let that go, even if this library fully embraces promises and async generators.

@benjamingr Even if promises and the microtask queue represent the highest-priority/smallest unit of asynchrony, the microtask queue is first-in-first-out, and a microtask which was scheduled before you kicked off rendering would win, potentially causing race conditions.

You had me until "potentially causing race conditions". I'm not sure what that means. Asynchronous code is harder than synchronous - that's for sure.

The fact that an element tree renders synchronously when it contains no async components is an incredibly useful invariant, and I’m extremely hesitant to let that go, even if this library fully embraces promises and async generators.

That's fine. I'm not saying it should, I think having the sync variant for API completeness and simplicity makes a lot of sense. I'm just saying it doesn't add any capabilities - though arguably a more obviously correct API is a capability.

Fun fact: jQuery deferred had that behaviour with sometimes sync, sometimes async. And it was a huuuge mistake. It costs us a few months of work when we had to migrate from jquery2 to jquery3 (where they moved to the standard promise behaviour of being always async)

Yeah, I’m going to say that having both sync and async generators is important, partly because supporting sync generators when you already support async generators is easy, but also because I really like the sync is sync, async is async design decision of Crank. I can say that as a person who’s done a lot of stuff with promises: Working with promises can be incredibly difficult and you’d have to be a masochist to willingly opt into promises when you don’t need them. And any framework which does batching or async DOM updates and calls it a feature and not a limitation is straight up pissing on your leg and calling it rain. It’s way more work to ensure that everything which can be synchronous happens synchronously, and the benefits is that the DOM is what you expect, exactly when you expect it.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

malcolmstill picture malcolmstill  Â·  5Comments

palavrov picture palavrov  Â·  6Comments

malcolmstill picture malcolmstill  Â·  4Comments

lazeebee picture lazeebee  Â·  5Comments

virtualfunction picture virtualfunction  Â·  5Comments