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鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

[RFC] synchronous vs asynchronous binding system for Aurelia 2 #1957

Open
bigopon opened this issue Apr 30, 2024 · 5 comments
Open

[RFC] synchronous vs asynchronous binding system for Aurelia 2 #1957

bigopon opened this issue Apr 30, 2024 · 5 comments
Labels
RFC Request For Comment - Potential enhancement that needs discussion and/or requires experimentation Topic: Binding Issues related to binding/observation
Milestone

Comments

@bigopon
Copy link
Member

bigopon commented Apr 30, 2024

馃挰 RFC for synchronous vs asynchronous binding systems

This RFC is an important topic of discussion before we move into release candidate (RC).

馃敠 Context

Currently, the binding system in Aurelia v2 is a synchronous system: changes are immediately notified at the time change happens, rather than queued and notified later, which is the approach in v1.

There are pros and cons to both types, though we will describe the issues from the perspective of a synchronous binding system.

鉂椻潡鉂桸ote: In the following example, consider @computed() as a way to express the getter property being subscribed to, and will immediately run their getter function whenever one of its dependencies changes.

1. the "glitch" problem

A synchronous binding system allows applications to wield great control over all assignments and mutations. Though it sometimes leads to glitches (current industry term) or impossible state.

Consider the following scenario:

object
  |
  + -> observer 1
  |        |
  |        + -> observer 1.1
  + -> observer 2

when a change happens in object, it is propagated to observer 1 and then observer 1.1 before hitting observer 2. If observer 1.1 happens to use a value from observer 2, it will be using a stale value of the object, since observer 2 hasn't received the new update yet.

An example of the above scenario is as follow:

class NameTag {
  firstName = '';
  lastName = '';

  @computed()
  get tag() {
    if (!this.firstName && !this.lastName) return '(Anonymous)';
    if (this.fullName.includes('Sync')) return '[Banned]';
    return `[Badge] ${this.fullName}`;
  }

  @computed()
  get fullName() {
    return `${this.firstName} ${this.lastName}`;
  }
}

In the above example, we are expecting to ban anyone with the word Sync in either their first or last name. But what happens is changing firstName to Sync won't result in a [Banned] tag but a [Badge] . This is because tag property (a computed observer because of @computed) subscribes to firstName first, and it will use the stale value from fullName when firstName changes.

Here, firstName being Sync but fullName being an empty string is what we call a "glitch", or an impossible state. This even though can be solved by not allowing cache on the computed value but we can face the same issue without computed values, and having computed values without caching could work for small scale of usage, but it could turn into performance issues.


Asynchronous binding systems, depending on their implementations, may or may not have this issue.

If upon the assignment of the value Sync to the firstName property, an asynchronous binding system only delays the change propagation to the next tick, and then propagates changes in the same way of the synchronous binding system, it will also face with glitches. This is just a "delayed" synchronous change notification.

If upon the assignment of the value Sync to the firstName property, an asynchronous binding system propagates changes of from observer to its subscribers, namely tag and fullName properties in our example, but those properties don't immediately recomputed and execute their logic and only start re-computing after all the subscribers have received the notifications to discard their stale computed value, then it's possible that this system will not have issues with "glitches". This mechanism is similar to way the signal proposal works (according to @EisenbergEffect), which is called "push then pull".

2. the "state tearing" problem

When there is a group of states that are supposed to be updated together, a synchronous binding system can cause premature change notification, and thus, premature recomputation.

Consider the following example:

class NameTag {
    firstName = '';
    lastName = '';

    update(first, last) {
        this.firstName = first;
        this.lastName = last;
    }

    @computed()
    get fullName() {
        if (!this.firstName || !this.lastName) {
            throw new Error('Only accepting names with both first and last names');
        }
        return `${this.firstName} ${this.lastName}`;
    }
}

const nameTag = new NameTag();
nameTag.update('John', 'Doe') // 馃挜

When calling the method nameTag.update('John', 'Doe'), an error is thrown as the assignment of the value John to the firstName property results in the computed property fullName be run prematurely.

Here firstName and lastName even though are visibly updated together at once in the code, don't always trigger changes together at once is what we call "state tearing". In a synchronous binding system, we often seen batch API provided as a way to hold the change propagation until after a certain point. Examples are our own batch or Solid batch


Asynchronous binding systems do not have this problem as changes normally are propagated at the next microtask/tick, which means all synchronous code will complete first.

馃搸 An evaluation of other frameworks

  • Some binding system offers a mixed approach: normal changes are acted synchronously, while computed values will be acted asynchronously. Such is the case with Svelte, as an example. You can test it via the following link: https://learn.svelte.dev/tutorial/reactive-declarations with the following code

    <script>
        let count = 0;
        $: console.log({count})
        $: doubled = count * 2;
        $: console.log({doubled})
    
        function increment() {
            Promise.resolve().then(() => console.log('count:', count))
            count += 1;
    
            console.log('hey')
        }
    </script>
    
    <button on:click={increment}>
        Clicked {count}
        {count === 1 ? 'time' : 'times'}
    </button>
    
    <p>{count} doubled is {doubled}</p>

    You'll see the console logs like this

    hey
    count: 1
    {count: 1}
    {doubled: 2}
    

    The fact that count: 1 being logged before {count: 1} and {double: 2} tells us that they are after the promise task, which is asynchronous.

  • Some binding system offers a full asynchronous approach with the ability to have one part stay in synchronous mode. Such is the case with Vue. An example can be seen from its doc https://vuejs.org/guide/essentials/watchers.html#sync-watchers

  • Some other systems, from other popular frameworks/platforms may offer some different ways of handling these problems, but the above examples (solid, svelte, Vue) seem to be the close and sufficient to describe some differences and options

馃搷 Options

  1. If it's our preferred option to remain as a synchronous binding system, then we will need to solve the "glitch" and "state tearing" issues.
  • To solve the "glitch" issue, we can employ the "push then pull" mechanism, which means changes will be propagated first to mark dirty states, before a 2nd round of actual recomputation. There may or may not be an issue when there's new changes in the middle of the recomputation, we will likely only have a clear answer with an actual implementation.
  • To solve the "state tearing" issue, we already have a "batch" function to help application manage the state updates so we likely won't need to do anything else here.

Remaining as a synchronous binding system can be quite compelling from the perspective of the effort required, especially considering we want to go RC within 1 - 2 more beta releases. A synchronous binding system also gives very predictable behavior (words copied from Solidjs doc), which is not always the case with asynchronous binding system, as there will be race condition when app grows without proper state management. Users of Aurelia 1 will be able to recognize this potential issue via the use of taskQueue.queueMicroTask when they face timing issues in their Aurelia v1 applications. (Thanks @Vheissu for mentoning this).

  1. If it's our preferred option to switch to an asynchronous binding system, then we will need to ensure our implementation are appropriate and can avoid all the issues mentioned above. We can take Aurelia v1 or the Vue system as the model, it may/may not be easy but the effort will likely to be big, and quite an uncertainty.
@bigopon bigopon added RFC Request For Comment - Potential enhancement that needs discussion and/or requires experimentation Topic: Binding Issues related to binding/observation labels Apr 30, 2024
@Vheissu
Copy link
Member

Vheissu commented May 1, 2024

Firstly, @bigopon, incredible write-up. Loved the examples of other frameworks/libs and explanation of the state-tearing problem.

From my perspective, the sync binding mode is a bit problematic, but once you know about it, it's easy to work around using something like batch to address the issue. Preempting this RFC and discussion was the example I posted in the team chat around being caught out by this. Still, other people I work with were also caught out by this because we're going from Aurelia 1 to Aurelia 2. It's an easy trap to fall into, assuming things like the binding system and how things update are the same.

Understandably, concessions had to be made, and we wanted to avoid some of the issues we had with Aurelia 1.

Right now, I think this is more of a documentation/awareness issue than a fundamental problem with Aurelia 2 itself. I don't want us to delay the release of candidates because of this. Fundamentally, there is nothing major here that needs to be fixed immediately. If synchronous binding updates were causing other issues, I would be more inclined to say we should bite the bullet and look into supporting async.

Not wanting to speak for everyone here, I think what we should do in the interim is:

  • Update the docs to highlight this behaviour and be very explicit in the way it works and leveraging batch to work around this difference compared to v1
  • Write a blog post about this (which will be helpful if someone skips the docs and goes to Google instead)

I am not married to any particular approach here. I am used to the Aurelia 1 way of updating because I have worked with it for as long as others I know. It was acknowledged in v1 that if you had issues around bindings, you would throw queueMicroTask in your code, and the problem was solved.

In the long term, we could always cut another major release if we decide to change it. We aren't stuck with these decisions. But I do think it's more destructive in the short term to delay a stable Aurelia 2 release because this is holding up wider adoption from other libraries and tooling (like official Storybook support, etc.).

My concerns and questions here relate to performance. Is the nature of sync that things are more predictable, or does one approach provide performance benefits over the other? Because I would support whatever approach results in better performance and fewer side effects over an approach that has the potential to slow my app down as the number of bindings increases.

@glyad
Copy link

glyad commented May 1, 2024

Probably, as an option... The binding should be a part of the transaction. The transaction should satisfy ACID rules. So,... I can't find too many use cases, where it may serve us.

@bigopon
Copy link
Member Author

bigopon commented May 6, 2024

... Is the nature of sync that things are more predictable, or does one approach provide performance benefits over the other?

There won't be any difference we can tell, apart from the fact that they are processed 1 tick different.

... The binding should be a part of the transaction. ...

Bindings are part of the transaction atm, and they'll behave like a computed example above.

@brandonseydel
Copy link
Member

I would think any user-initiated event should auto batch imo. I am not sure of the consequences of this behavior but seems like the correct thing to do. 馃し

@bigopon
Copy link
Member Author

bigopon commented May 8, 2024

I would think any user-initiated event should auto batch imo. I am not sure of the consequences of this behavior but seems like the correct thing to do. 馃し

one consequences of this behavior is user initiated event would run code differently, say update() from a click will be different with an interval/timeout. One argument for batching automatically is that most of the code runs when user interacts and we should expect binding to work in the reactive way. We can also test it and give the ability to turn that off. This would probably
help avoid all state tearing issues, but more of hide them.

@bigopon bigopon added this to the v2.0-rc milestone May 11, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
RFC Request For Comment - Potential enhancement that needs discussion and/or requires experimentation Topic: Binding Issues related to binding/observation
Projects
None yet
Development

No branches or pull requests

4 participants