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

[rfc] support for signals in lwc templates #82

Open
wants to merge 4 commits into
base: master
Choose a base branch
from

Conversation

caridy
Copy link
Contributor

@caridy caridy commented Nov 8, 2023

Copy link

salesforce-cla bot commented Nov 8, 2023

Thanks for the contribution! Before we can merge this, we need @caridy to sign the Salesforce Inc. Contributor License Agreement.

@caridy
Copy link
Contributor Author

caridy commented Nov 8, 2023

@jodarove @divmain I could not find the original work that we did on this years ago.

@jodarove
Copy link
Contributor

jodarove commented Nov 9, 2023

@jodarove @divmain I could not find the original work that we did on this years ago.

It is on this branch: https://github.com/salesforce/lwc/tree/jodarove/lwc-stores-v1

}
```

In this design, `this.$api` is a reactive wrapper around the component's API properties, enabling stores to react to changes in properties like recordId. This reensambles the internal mechanism used today in LWC via `vm.cmpProps` that is not exposed to developers.

Choose a reason for hiding this comment

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

Is this reactive wrapper also a signal-like thing? That is, would RecordStore in this case be expected to do .value and .subscribe?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yes! the argument would be a signal-like object created by LWC.

Choose a reason for hiding this comment

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

Would RecordStore be expected to do any .unsubscribe cleanup when the component is disconnected/disposed?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I would update the API to remove the unsubscribe all together to match what other frameworks are doing. As for the development experience, they should try to unsubscribe, but LWC would not track any of that. When LWC uses (internally) an store, it would unsubscribe accordingly

Copy link
Contributor

Choose a reason for hiding this comment

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

reensambles

?

Copy link
Contributor

Choose a reason for hiding this comment

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

new RecordStore(this.$api.recordId)

How does this allow the RecordStore to react to changes in recordId? It's receiving the value, not the signal, correct? Or is $api an object where the props are strings and the values are signals?

### Potential for Misuse

* __Overuse of Signals__: There's a risk that developers might overuse signals for state management, leading to unnecessarily reactive code, which can be less performant and harder to maintain. This is specially relevant because of the runtime detection of `.value` to maintain backward compatibility with existing components. Any misused on the signals can affect the runtime performance of the component and therefore the overall performance of the application.
* __Subscription Management__: Incorrect handling of subscriptions could introduce memory leaks or lead to unexpected behaviors if not managed correctly. This is specially important because the store implementation doesn't have access anymore to the component's lifecycle hooks, instead it would just stop receiving updates of the stores piped thru `this.$api`, meaning that we are at the mercy of the garbage collector.

Choose a reason for hiding this comment

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

This is the one that concerns me the most. I can see lots of component authors messing up subscriptions. @wire has issues, but one of the nice things about it is that there's no way for a component author to mess up the subscription.


### Integration with Components

Components should treat signals as internal reactive state mechanisms. While a signal can technically be passed down to child components, it's recommended to pass only the necessary data, typically the `.value` of the signal, to keep the child components decoupled from the parent's state management strategy.

Choose a reason for hiding this comment

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

I understand & agree with the sentiment here, but I think injecting a signal into a component is going to be the key to making low-code scenarios work?

Choose a reason for hiding this comment

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

@caridy I think I remember a demo when signals were introduced for either Solid or React, where signals were explicitly used to skip levels inside component hierarchies (kind of like a poor-man's state management), but where this was key to some major performance improvements observed. Using the following technique there lead to signals "magically" updating their values inside the HTML elements directly, without relying/executing a full re-render of the involved components. I know that this would scare the hell out of you, but that approach was like what brought "lightning" to the demo back then.

// bundle `acme/signals`
import { signal } from 'signals';

export const count = signal(0);
// bundle `acme/parent`
import { count } from 'acme/signals';

class Parent extends LightningElement {
    handleClick() {
         count.value++;
    }
}
// bundle `acme/grandGrandChild`
import { count } from 'acme/signals';

class GrandGrandChild extends LightningElement {
    get count() {
        return count;
    }
}
<!-- bundle `acme/grandGrandChild` -->
<div>{count.value}</div>

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The only thing to really try to figure out is whether or not we should support {count} vs {count.value} in the templates, a la-vue. The hesitation here is the signal down to other components, the ambiguity he being:

<template>
   <input value={count} />
   <x-foo count={count}></x-foo>
</template>

Should count property on foo receive a signal or its value? That's the problem.

Copy link
Contributor

Choose a reason for hiding this comment

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

it's recommended to pass only the necessary data

Let's assume for a second that our users won't always do the "recommended" thing. (A very good assumption!)

  • What kinds of guardrails can we put in place? Warnings/errors/etc.?
  • What happens if they mess it up? What are the consequences?
  • Knowing all this, should we make it possible to do the wrong thing?

Using the following technique there lead to signals "magically" updating their values inside the HTML elements directly, without relying/executing a full re-render of the involved components.

This is called fine-grained reactivity. It is definitely a perf boost, but I'm not convinced that signals are required to achieve it: salesforce/lwc#3624

}

connectedCallback() {
this.record.subscribe(record => this.updateRecord(record));

Choose a reason for hiding this comment

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

Can this happen before connectedCallback, eg in constructor?

@mburr-salesforce
Copy link

I don't think so, but does anything here prevent signals from provisioning values to the component before its initial render? That is, using a signal does not automatically force my component to be rendered twice?

@mburr-salesforce
Copy link

I expect it would take someone a couple days to figure out they can do something like this:

import type { WireAdapter, WireConfigValue, WireDataCallback } from 'lwc'

type SignalCallback = (value: any) => void
type Signal = {
    value: any
    subscribe: (callback: SignalCallback) => void
    unsubscribe: (callback: SignalCallback) => void
}

export default class SignalWire implements WireAdapter {
    signal?: Signal

    constructor(private callback: WireDataCallback) {
    }

    connect(): void {
        this.handleSubscriptions(this.signal)
    }

    disconnect(): void {
        this.handleSubscriptions()
    }

    update(config: WireConfigValue): void {
        this.handleSubscriptions(config.signal)
    }

    private handleSubscriptions(signal?: Signal): void {
        if (this.signal) {
            this.signal.unsubscribe(this.callback)
        }

        this.signal = signal
        if (signal) {
            this.callback(signal.value)
            signal.subscribe(this.callback)
        }
    }
}

Should we just preemptively provide this?


## Alternatives

### 1. Continued Use of `@track` and `@wire`

Choose a reason for hiding this comment

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

@mburr-salesforce @caridy It would be an interesting option to have wires that pass signals instead of deeply frozen objects. That would open things up for a lot of simplifications when for instance passing wired values into e.g. forms without having to create deep clones. But it would of course also open things up for all the mentioned misuse. Still something to assess further in terms of perf pros and cons maybe. Definitely would have an impact on perf, but also on development models as it would allow e.g. mutations without following established approaches like data down and events up.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That would be a choice to make for the owner of the adapter, not LWC.

@caridy
Copy link
Contributor Author

caridy commented Nov 13, 2023

I don't think so, but does anything here prevent signals from provisioning values to the component before its initial render? That is, using a signal does not automatically force my component to be rendered twice?

@mburr-salesforce that's correct. Since a signal can be initialized, plus its callback can be called at any given time, a component with access to a signal can gain access to a value for the first render.

@caridy
Copy link
Contributor Author

caridy commented Nov 13, 2023

I expect it would take someone a couple days to figure out they can do something like this:

import type { WireAdapter, WireConfigValue, WireDataCallback } from 'lwc'

type SignalCallback = (value: any) => void
type Signal = {
    value: any
    subscribe: (callback: SignalCallback) => void
    unsubscribe: (callback: SignalCallback) => void
}

export default class SignalWire implements WireAdapter {
    signal?: Signal

    constructor(private callback: WireDataCallback) {
    }

    connect(): void {
        this.handleSubscriptions(this.signal)
    }

    disconnect(): void {
        this.handleSubscriptions()
    }

    update(config: WireConfigValue): void {
        this.handleSubscriptions(config.signal)
    }

    private handleSubscriptions(signal?: Signal): void {
        if (this.signal) {
            this.signal.unsubscribe(this.callback)
        }

        this.signal = signal
        if (signal) {
            this.callback(signal.value)
            signal.subscribe(this.callback)
        }
    }
}

Should we just preemptively provide this?

We are providing a mechanism to interact, reactively, with LWC engine, that's all. If people want to do these things... sure. Not sure I understand the point though.

@mburr-salesforce
Copy link

We are providing a mechanism to interact, reactively, with LWC engine, that's all. If people want to do these things... sure. Not sure I understand the point though.

@caridy The point is that it's possible to use @wire to do subscription management for your signals. Your @wired function just becomes your signal subscription callback & everything else is handled for you.

```javascript
export default class ExampleComponent extends LightningElement {
@api recordId;
record = new RecordStore(this.$api.recordId);
Copy link
Contributor

Choose a reason for hiding this comment

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

Why the dollar sign on $api? Historically we have not put dollar signs for component-specific props like this.refs and this.template.

If the concern is backwards compatibility, then we can gate this behind an API version.


1. __Framework Agnosticism__: With Signals, LWC components can interact with any external data store that follows the protocol. This change would make LWC more agnostic and flexible, allowing for easier integration with various state management patterns and libraries.
1. __Simplified State Management__: Signals provide a straightforward way to manage reactive data. By deprecating the @wire and @track decorators, we can reduce complexity, making it easier for developers to write and maintain component logic.
1. __Performance Optimizations__: The proposed model opens up possibilities for performance optimizations by allowing selective rehydration of the UI. Components could update the DOM directly in response to state changes without a full re-render, leading to faster updates and reduced resource consumption.
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: it's not clear to me that signals are required to achieve fine-grained reactivity. E.g. the design described in salesforce/lwc#3624 calls for adding fine-grained reactivity to template loops, without any user-facing API changes.

Also, it is entirely possible to implement signals while still re-running the entire template. It would be useful to see examples where signals unlock perf optimizations that we couldn't get otherwise.


It is important to highlight that this is the less common method of using a wire adapter. With the new approach, it becomes a little bit more cumbersome, but it is still possible to do it, and it offers a lot more flexibility since you can now use any store that follows the protocol, not just Salesforce-specific services.

## Backward Compatibility
Copy link
Contributor

Choose a reason for hiding this comment

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

The easiest way to achieve backwards compatibility would be to use API versioning. As a bonus, we could disallow @wire and @track based on an API version. (May need to be loosened for first-party components.)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't think we need to remove either of those to have a fully functional signal integration. They are just two separate features, and eventually we can start the deprecation strategy.

Copy link
Contributor

Choose a reason for hiding this comment

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

Although it'd be really attractive to retire @wire, I think it shouldn't be particularly emphasized in the signals discussion. Initially, signals (or whatever alternative we may come up with) should be purely additive. Long term, we can look at retiring @wire or reimplementing its internals with signals.

## Open Discussion Topics

* The signal/store protocol. Except for the `.value`, which seems to be a requirement, the rest of the protocol is open for discussion.
* The `this.$api` as a way to access internal signal objects created by LWC, that seems to be a good compromise, but we can discuss alternatives.
Copy link
Contributor

Choose a reason for hiding this comment

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

It would be great to have examples from other frameworks in this document. I know nearly every framework is moving to signals these days, but I'm not familiar with the exact API patterns used in each one.

Copy link
Contributor

Choose a reason for hiding this comment

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

Agreed 100%. I'm going to attempt to put together a survey of signals implementations & alternate approaches across web frameworks ahead of our dev sync discussion.

position = signal({ x: 0, y: 0 });

move(x, y) {
this.position.value = { ...this.position.value, x, y };
Copy link
Contributor

Choose a reason for hiding this comment

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

This does become a challenge for nested objects from these two perspectives:

  1. Error prone trying to recreate a new object for every combination of change
  2. Requires boiler plate code to recreate the object.

Copy link
Contributor

Choose a reason for hiding this comment

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

It's also potentially a perf tax due to cloning the object.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

sure. but I don't think this RFC is about that particular aspect of it, but the enablement of stores.

Copy link
Contributor

Choose a reason for hiding this comment

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

This does become a challenge for nested objects from these two perspectives:

  1. Error prone trying to recreate a new object for every combination of change
  2. Requires boiler plate code to recreate the object.

Because this RFC deals with the low-level primitives that we'd provide, this isn't a huge concern to me. I'm hoping/expecting that this won't be heavily used by individual components and, instead, abstractions (state manager, data provider, etc) would be built on top of signals that provide better ergonomics.

For example, in a state manager, we could use/replicate Immer to provide a friendlier facade.

@AllanOricil
Copy link

@caridy if I have N child components on the same level (siblings), all subscribed to the same signal, would there exist a way to determine the order components are updated and re-rendered?

@divmain
Copy link
Contributor

divmain commented Nov 15, 2023

@AllanOricil

if I have N child components on the same level (siblings), all subscribed to the same signal, would there exist a way to determine the order components are updated and re-rendered?

This is a good question. Without deliberately implementing something else, the default behavior would rely on the order that components had subscribed to the signal. Is there a specific scenario you're thinking through where you'd want some other, deterministic behavior?

@AllanOricil
Copy link

AllanOricil commented Nov 15, 2023

@AllanOricil

if I have N child components on the same level (siblings), all subscribed to the same signal, would there exist a way to determine the order components are updated and re-rendered?

This is a good question. Without deliberately implementing something else, the default behavior would rely on the order that components had subscribed to the signal. Is there a specific scenario you're thinking through where you'd want some other, deterministic behavior?

I was thinking about a page with lots of siblings components, most of which are outside of the viewable bounds, all subscribed to the same signal. If a signal is updated, I would want to prioritize the ones that are on the view to provide a better ux. So I was thinking that maybe there could exist a reactive/dynamic index that would allow one to control the order components get updated. Does it make any sense?

@AllanOricil
Copy link

I could not find a doc where solid describes its heuristics so I asked Bing gpt hehe

obs: I did not check any info showed below

"When a signal changes, Solid uses a heuristic called topological sorting to determine the order in which components should be re-rendered. Topological sorting is a graph algorithm that sorts nodes in a directed acyclic graph (DAG) such that for every directed edge (u, v), node u comes before node v in the ordering. In the context of Solid, the nodes are components, and the edges are the dependencies between signals and components.

The topological sorting algorithm works by first finding all nodes with no incoming edges (i.e., no dependencies), adding them to the ordering, and then removing them from the graph. This process is repeated until all nodes have been added to the ordering. If there are cycles in the graph (i.e., circular dependencies), then the algorithm will detect them and raise an error.

In summary, Solid uses topological sorting to determine the order in which components should be re-rendered when a signal changes. This ensures that components are re-rendered in the correct order and that circular dependencies are detected and handled appropriately.

Source: Conversation with Bing, 11/16/2023
(1) Using signal outside of component. · solidjs solid - GitHub. solidjs/solid#397.
(2) Documentation - SolidJS · Reactive Javascript Library. https://www.solidjs.com/docs/latest.
(3) Sharing Signals and Stores: Context API in SolidJS. https://www.thisdot.co/blog/sharing-signals-and-stores-context-api-in-solidjs.
(4) undefined. https://codesandbox.io/s/derivation-j0nzm?file=/index.js."


"When there are multiple components on the same level in the tree, Solid uses the order in which the components are defined to determine the order in which they should be re-rendered.

For example, consider the following code snippet:

function App() {
  const [count1, setCount1] = createSignal(0);
  const [count2, setCount2] = createSignal(0);

  return (
    <>
      <Counter count={count1} setCount={setCount1} />
      <Counter count={count2} setCount={setCount2} />
    </>
  );
}

function Counter({ count, setCount }) {
  return (
    <div>
      <p>Count: {count()}</p>
      <button onClick={() => setCount(count() + 1)}>Increment</button>
    </div>
  );
}

In this example, App has two Counter components at the same level in the tree. The order in which the components are defined in the return statement of App determines the order in which they will be re-rendered. So if Counter 1 is defined before Counter 2, then Counter 1 will be re-rendered before Counter 2 when a signal changes.

I hope that helps! Let me know if you have any other questions.

Source: Conversation with Bing, 11/16/2023
(1) Using signal outside of component. · solidjs solid - GitHub. solidjs/solid#397.
(2) Documentation - SolidJS · Reactive Javascript Library. https://www.solidjs.com/docs/latest.
(3) Signals: Fine-grained Reactivity for JavaScript Frameworks. https://www.sitepoint.com/signals-fine-grained-javascript-framework-reactivity/.
(4) undefined. https://codesandbox.io/s/derivation-j0nzm?file=/index.js."

@AllanOricil
Copy link

AllanOricil commented Nov 16, 2023

I thought about a different heuristic to determine the order components react to signal changes. What if you think that the signal source is like an antenna broadcasting a change in a 3D space? Then you could order updates with these 2 rules:

  • components closer to the signal source are updated first. Depth represents weight/distance of 1.
  • components that are inside the viewable box have preference, but the ones with shorter distances are updated first.

You could even create the definition of single signal broadcasting multiple changes in a single event, as if each information was encoded using different frequencies, in order to avoid emitting n separate events at different times.

Is this stupid? I just made an analogy based on how telecommunications systems work

@mburr-salesforce
Copy link

@AllanOricil The order in which a signal notifies its subscribers and the order in which LWC chooses to re-render components seem like orthogonal concerns? Trying to orchestrate rendering order within the scope of a single signal is going to miss many factors that could cause a component to re-render.

@AllanOricil
Copy link

AllanOricil commented Nov 16, 2023

@AllanOricil The order in which a signal notifies its subscribers and the order in which LWC chooses to re-render components seem like orthogonal concerns? Trying to orchestrate rendering order within the scope of a single signal is going to miss many factors that could cause a component to re-render.

I thought the order an event is received would change the order components are re-rendered. Doesn't the render engine re-render all components that changed in a time frame?

@mburr-salesforce
Copy link

I thought the order an event is received would change the order components are re-rendered. Doesn't the render engine re-render all components that changed in a time frame?

I'm not sure how LWC determines in what order components should be re-rendered. A few things that make me reluctant to overlay rendering concerns on a signal:

  • Not all subscribers to a signal will be components. Eg we have use cases where a signal will subscribe to another signal.
  • Lots of things that cause a component to re-render do not involve a signal. Eg parent changes an @api property, an @wire emits a new value, etc.
  • Even when a component subscribes to a signal, a new value from the signal might not result in a re-render. Eg when the data that changed is not used in the template.

}
```

It is important to highlight that this is the less common method of using a wire adapter. With the new approach, it becomes a little bit more cumbersome, but it is still possible to do it, and it offers a lot more flexibility since you can now use any store that follows the protocol, not just Salesforce-specific services.

Choose a reason for hiding this comment

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

Most of the internal code I see uses wire methods, not properties. Do we have numbers on the breakdown?

@api recordId;
record = new RecordStore(this.$api.recordId);

updateRecord(record) {

Choose a reason for hiding this comment

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

It would be great to flesh out the error handling some more.

  • Can creating a store cause an error?
  • Do methods have an error object or is there a different mechanism for communicating errors to components? Error handling frequently breaks static analysis, so if there's a way to handle them separately from the data, that would be nice.

For low-code, we are moving towards completely presentational components that do not make data calls. The view metadata binds the data to the components, so the framework is responsible for handling errors. You can envision an admin saying "Show this error component if a data failure occurs".

get value(): any { /* return current value */ }
subscribe((newValue: any) => void): () => void { /* subscribe to changes */ }
}
```

Choose a reason for hiding this comment

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

How can ISVs package a store? In the past, we've asked for headless components for things like headless actions but ended up having to package it as an LWC even though it's very awkward.

We have actual use cases where we need the store to be on the side (vs in the DOM) to solve problems. Use Case: With Islands architecture, I have a mostly static page (no app layer JS) and two small interactive regions that rely on the same state.

@caridy
Copy link
Contributor Author

caridy commented Nov 29, 2023

@AllanOricil @nolanlawson @mburr-salesforce I have to work on splitting this into two, bare with me as I recover, and catch up with pending work.

Great questions. All I can say at the moment is that it depends on how LWC uses the signal. One of my goals is to get to the point where signals DO NOT trigger re-rendering, at least for simple interpolation, like it is the case today. @nolanlawson has been working on this as well for a while. But clearly we haven't think through all the implications here.

Today, LWC does implement an algorithm to determine the proper order to rehydrate, but if there is no re-rendering, then what?

@caridy caridy changed the title first draft of the rfc for signals in lwc [rfc] support for signals in lwc templates Dec 1, 2023
@caridy
Copy link
Contributor Author

caridy commented Dec 1, 2023

Folks, I have updated the PR to split the RFC into two. This now only contains the details of how to use signals in LWC templates, without adding any new API to LWC.

Copy link
Contributor

@nolanlawson nolanlawson left a comment

Choose a reason for hiding this comment

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

Overall this looks reasonable to me, but I think this RFC is still missing a competitive analysis of what other frameworks are doing and whether we want to align or diverge from them.

Signals are a huge topic right now among JS frameworks – everyone is either adopting them or looking to adopt them. Outlining what other frameworks are doing would provide a lot of useful context for this proposal, rather than trying to design in a vacuum.

text/0000-signals-in-lwc-templates.md Show resolved Hide resolved
text/0000-signals-in-lwc-templates.md Show resolved Hide resolved

1. __Limited External Store Interaction__: LWC components cannot directly interact with external state management systems without going through LWC-specific APIs. This restricts the framework's flexibility and developer freedom to integrate with the broader JavaScript ecosystem.
1. __Complexity and Overhead__: The use of a membrane based on proxies introduces additional complexity and overhead in component design and state management, from read-and-write to read-only objects, it can be challenging for developers to debug and reason about data flow and updates.
1. __Performance Constraints__: The current model relies on components to re-render fully to reflect state changes, which can be inefficient, especially for large and complex component trees.
Copy link
Contributor

Choose a reason for hiding this comment

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

I see this as orthogonal, especially because for a v1, we will probably just re-render the whole template anyway (similar to the current system). We'd have to implement fine-grained reactivity to change that.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

well, I think the hint of a .value in the template, it is certainly the difference. If we were to continue doing what we are doing, we could get far, but because we don't know what is reactive and what's not reactive, I suspect we might have perf challenges.

Copy link
Contributor

Choose a reason for hiding this comment

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

You know what's reactive, but that doesn't mean you necessarily want fine-grained reactivity at the level of every observable property. Even Solid batches them together in some cases.

Without Signals, I think we can batch at a very coarse level, e.g. for individual items in a for:each iteration, and get a lot of bang for our buck.

My main point is that I wouldn't sell Signals based on perf. I'd sell it based on DX.

text/0000-signals-in-lwc-templates.md Outdated Show resolved Hide resolved

### Integration with Components

Components should treat signals as internal reactive state mechanisms. While a signal can technically be passed down to child components, it's recommended to pass only the necessary data, typically the `.value` of the signal, to keep the child components decoupled from the parent's state management strategy.
Copy link
Contributor

Choose a reason for hiding this comment

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

What do you think about formally restricting this, at least in a v1? This could be relaxed later if there are valid use cases for passing down the signal itself.

(I.e. the runtime engine could detect that an object looks like a signal and was passed down wholesale. This might be a breaking change due to false positives on objects that merely look like signals, but it could be done with API versioning.)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

How? today you do allow passing objects, and non-plain objects as well. Do you plan to just throw if the object has a .value and .subscribe? that seems expensive, for a very little value.

text/0000-signals-in-lwc-templates.md Outdated Show resolved Hide resolved
text/0000-signals-in-lwc-templates.md Outdated Show resolved Hide resolved
text/0000-signals-in-lwc-templates.md Show resolved Hide resolved
There are two main concerns with backward compatibility:

1. A component receiving an object from a parent component that contains a `value` getter and a `subscribe` method. If the object is considered a POJO by our current test, then LWC would incorrectly avoid wrapping the object with a read-only reactive process. This is a minor concern, but it is something to consider.
2. A template using `x.value` as part of an interpolation where the value of `x` is not a signal, but contains a `value` getter and a `subscribe` method. In this case, the compiled code would attempt to call `subscribe`, if it fails, we can swallow the error and show a warning. In terms of functionality, everything remains the same, the `.value` is used to render the value into the DOM, and if it is reactive it will be updated automatically by triggering the re-rendering, which would call the `.value` getter again. Technically, this is not a non-backward compabitibility issue since we can swallow the error. But it is something to consider.
Copy link
Contributor

Choose a reason for hiding this comment

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

These are both solvable with API versioning IMO – if you don't bump your <apiVersion>, you don't get signal reactivity magic.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't think we need to. If the library implementing the signals can take care of the first point, then we should be fine. Since this RFC doesn't cover that part, we can discuss the details on the next RFC.


### 1. Adoption of Other Reactivity Models

Other frameworks' reactivity models, such as starbeam, Vue's reactivity system or Svelte's store contracts, could be considered. Each comes with its own set of trade-offs and would require significant adaptation to align with LWC's architecture and principles.
Copy link
Contributor

Choose a reason for hiding this comment

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

I would love to see a rundown of other frameworks' reactivity systems, and if there is anything we can learn from them, or if there is an emerging standard we can rely on, etc.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@nolanlawson certainly. I also want to get @wycats involved here.

Comment on lines +115 to +125
Objects can be wrapped in Signals to make them reactive. However, changes to nested properties must be handled by replacing the entire object:

```javascript
import { signal } from 'signals';

export default class ExampleComponent extends LightningElement {
position = signal({ x: 0, y: 0 });

move(x, y) {
this.position.value = { ...this.position.value, x, y };
}
Copy link

@AllanOricil AllanOricil Dec 16, 2023

Choose a reason for hiding this comment

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

What if this behavior is configurable?

position = signal({x:0, y:0}) //has to change the whole object

position = signal({x:0,y:0}, { deep: true }) //deep comparison will be made which enables signaling based on prop changes

It is like vue's watch.prop.deep, but for signals

I think you have to describe what happens when binding object props to templates. Can I bind the whole object like using <div>{{position.value}}</div>? or do I need to use <div>{{position.value.x}}</div> or would it be <div>{{position.x.value}}</div>?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@AllanOricil all that is possible, but not part of this RFC. Remember, this is about the template, and how to use signals in the template, how to create and update signals, that's a different RFC.

As for the specific syntax in the template, you will probably need to do: <div>{{position.value.x}}</div>. The .value is the signal to listen for changes at runtime.

Comment on lines +52 to +59
In the template, we can bind directly to the `.value` property:

```html
<template>
<button onclick={increment}>Increment</button>
<p>{count.value}</p>
</template>
```

Choose a reason for hiding this comment

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

What happens if I bind with count?
I really did not like to be forced to use .value all the time. I recall you have already answered this question but I could not find your answer.

Choose a reason for hiding this comment

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

What if lwc provides ref and unref like vue 3?

https://markus.oberlehner.net/blog/vue-3-composition-api-ref-vs-reactive

Copy link
Contributor Author

Choose a reason for hiding this comment

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

the problem of not having a hint (e.g.: .value), then you have two options:

  1. introduce a new syntax, to hint when to use it as a signal.
  2. introduce a big overhead to the template runtime execution to test every binding to see if the binding is a signal (I don't think we can afford this).


This ensures that child components remain agnostic of the parent's state management implementation, promoting better component encapsulation and reuse.

In terms of optimizations, the compiler can detect when a signal is passed down to a child component because of the `.value` in the template interpolation, and generate code to subscribe to the signal's changes. This would allow the parent component to update the child's public property `count` without a full re-render, leading to better performance. On the receiving end, because the child component's `count` property is not a signal, but a value, it will react to changes as usual, without any special handling.

Choose a reason for hiding this comment

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

What is the use case for being able to pass the signal ref instead of .value to a child component? Again, as a dev I really did not like to use count.value. If there is no use case, I think we could just use count. Then the framework can identify that it is a signal based on its type (maybe you could officially make lwc ts first to get errors at build time) and that the child has to react based on .value

Copy link
Contributor Author

Choose a reason for hiding this comment

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

there are 3 ways to provide stores to a component:

  1. create or import a signal object
  2. use a global signal (not recommended)
  3. get it via a prop

So take your poison. If you have two side by side components that are using the same store provisioned by a parent component, what else can you do other than 3?

There are two main concerns with backward compatibility:

1. A component receiving an object from a parent component that contains a `value` getter and a `subscribe` method. If the object is considered a POJO by our current test, then LWC would incorrectly avoid wrapping the object with a read-only reactive process. This is a minor concern, but it is something to consider.
2. A template using `x.value` as part of an interpolation where the value of `x` is not a signal, but contains a `value` getter and a `subscribe` method. In this case, the compiled code would attempt to call `subscribe`, if it fails, we can swallow the error and show a warning. In terms of functionality, everything remains the same, the `.value` is used to render the value into the DOM, and if it is reactive it will be updated automatically by triggering the re-rendering, which would call the `.value` getter again. Technically, this is not a non-backward compabitibility issue since we can swallow the error. But it is something to consider.

Choose a reason for hiding this comment

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

Couldn't you be sure that it is a signal at build time using ts? This would help the engine to do less checks at runtime

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@AllanOricil No, the compiler compiles one file at a time in isolation.

Co-authored-by: Nolan Lawson <nolan@nolanlawson.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

9 participants