Skip to content

Comments

feat: signals implementation v1#3963

Merged
jmsjtu merged 21 commits intomasterfrom
jtu/signals
Feb 7, 2024
Merged

feat: signals implementation v1#3963
jmsjtu merged 21 commits intomasterfrom
jtu/signals

Conversation

@jmsjtu
Copy link
Member

@jmsjtu jmsjtu commented Jan 27, 2024

Description

This is an initial implementation of the signals RFC with the following deviations:

  • The LWC engine will attempt to subscribe to the signal when it has been bound to an LWC class and when it is used on a template.
  • The .value doesn't have to be used on the template, it could simply be the signal itself, although, it will render as [object object].

Branch with the playground updated for signals.

Detailed Design

The engine will attempt to subscribe to a signal if it is attached to an LWC and if it's used on a template.

Signals bound to an LWC

Mirroring the existing reactivity, we use the getter on the LWC to determine if the value resembles the shape of a signal as defined in @lwc/signals.

The callback that the engine subscribes is the re-render callback assigned to the template reactive observer.

const { isDirty } = vm;
if (isFalse(isDirty)) {
    markComponentAsDirty(vm);
    scheduleRehydration(vm);
}

Every time the template re-renders, we call the unsubscribe callback as part of the clean up process. This coincides with whenever the template reactive observer is reset.

Signals used on a template

Subscription to signals occurs during the process of generating the vnodes, for example:

function tmpl($api, $cmp, $slotset, $ctx) {
  const {d: api_dynamic_text, t: api_text, h: api_element} = $api;
  return [api_element("h1", stc0, [api_text("this is the signal value: " + api_dynamic_text($cmp.signal.value))])];
  /*LWC compiler v6.0.0*/
}

Notice the $cmp.signal.value, the getter is accessed at this moment and the routine runs to subscribe.

Does this pull request introduce a breaking change?

  • ✅ No, it does not introduce a breaking change.

All changes are gated behind a feature flag.

Does this pull request introduce an observable change?

  • ✅ No, it does not introduce an observable change.

All changes are gated behind a feature flag.

GUS work item

W-14560945

@jmsjtu jmsjtu requested review from a team and divmain and removed request for a team January 27, 2024 02:39
@jmsjtu jmsjtu added the nomerge label Jan 27, 2024
@jmsjtu jmsjtu changed the title feat: signals implementation feat: signals implementation v1 Jan 29, 2024
'value' in target &&
'subscribe' in target &&
typeof target.subscribe === 'function'
) {
Copy link
Contributor

Choose a reason for hiding this comment

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

These conditions look right to me for now. Sometime after this PR lands, we may want to replace the last three conditions with something like && target.isSignal === SUPER_SECRET_SYMBOL, where const SUPER_SECRET_SYMBOL === Symbol("SymbolSigil"). This would allow us to lock down initial usage of the signal machinery by restricting who has access to that symbol in the runtime (i.e. so that customers can't start using signals before we're ready).

subscriber();
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

As we discussed, I'd love for this to be moved to a new top-level @lwc/signals package that exposes the types and base class. I would have recommended moving to @lwc/shared, except that is intended as an internal package with other @lwc/* packages being the only direct consumers. In contrast, the signals type and base call may be re-used across the SF ecosystem.

Copy link
Member Author

Choose a reason for hiding this comment

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

@divmain I moved everything to a new package and added a readme.

If you get a chance, can you look through the readme too?

Comment on lines +3 to +6
This is an experimental package containing the interface expected for signals.

A key point to note is that when a signal is both bound to an LWC class member variable and used on a template,
the LWC engine will attempt to subscribe a callback to rerender the template.
Copy link
Member Author

Choose a reason for hiding this comment

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

I wrote a quick readme based on our internal docs and the RFC that caridy wrote. Open to changing the wording on any of this!

Copy link
Contributor

Choose a reason for hiding this comment

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

As mentioned above, I think for now this should probably just be in @lwc/engine-core. We can decide later if we want to expose a brand-new package out of this.

Copy link
Contributor

Choose a reason for hiding this comment

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

@nolanlawson let's chat offline about this. Having a separate package will help facilitate the related work that is coevolving.

* to the same signal. Additionally, it keeps track of all signal unsubscribe callbacks, handles invoking
* them when necessary and discarding them.
*/
class SignalTracker {
Copy link
Member Author

Choose a reason for hiding this comment

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

I created this module to keep track of LWC component instances to signals bound the LWC (the signal is attached to the LWC class somewhere).

The reason this is needed is because during the rendering process, if a signal is referenced more than once on a template its getter is called multiple times.

Since we're hooking into the class' getter to determine whether a property access is a signal, there may be multiple attempts to subscribe the same callback to a single signal.

I thought about reusing the mutation-tracker library to track this information but decided against it to keep the abstraction clean.

The mutation-tracker library only really cares about the state within its own module and I felt introducing the concept of signals to it would pollute the abstraction.

tro.isObserving()
) {
// Subscribe the template reactive observer's notify method, which will mark the vm as dirty and schedule hydration.
subscribeToSignal(component, target, tro.notify.bind(tro));
Copy link
Member Author

@jmsjtu jmsjtu Feb 2, 2024

Choose a reason for hiding this comment

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

You might be wondering why I'm exporting and using the signal-tracker inside the mutation-tracker in @lwc/engine-core when I was trying to keep the mutation-tracker library clean.

Note mutation-tracker in @lwc/engine-core is a separate package from mutation-tracker in libs

The reason is because the only real function I could see exporting from a signal-tracker module in @lwc/engine-core was something that would export a similar function as valueObserved.

Rather than create a separate function, I decided to reuse it since it's already aware of things outside the mutation-tracker library, like the vm.

Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
subscribeToSignal(component, target, tro.notify.bind(tro));
subscribeToSignal(component, target, FunctionBind.call(tro.notify, tro));

Copy link
Member Author

Choose a reason for hiding this comment

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

@nolanlawson it looks like we don't have a FunctionBind extracted in @lwc/shared.

I plan to open a separate PR to expose some of the common function methods and I'll update it here as part of the PR.

@jmsjtu jmsjtu marked this pull request as ready for review February 2, 2024 20:35
@jmsjtu jmsjtu requested a review from a team as a code owner February 2, 2024 20:35
@jmsjtu jmsjtu requested review from divmain and nolanlawson and removed request for a team February 2, 2024 20:35
@jmsjtu jmsjtu removed the nomerge label Feb 2, 2024
"version": "6.0.0",
"description": "Provides the protocol to to expose reactivity outside of the LWC framework",
"keywords": [
"lwc"
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
"lwc"
"lwc",
"signals"

🤷

{
"path": "packages/@lwc/engine-dom/dist/index.js",
"maxSize": "22KB"
"maxSize": "24KB"
Copy link
Contributor

Choose a reason for hiding this comment

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

📈

"devDependencies": {
"observable-membrane": "2.0.0"
"observable-membrane": "2.0.0",
"@lwc/signals": "6.0.0"
Copy link
Contributor

Choose a reason for hiding this comment

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

This should be a dependency, or else it's gonna break things! (Alternatively, make the imports in mutation-tracker / signal-tracker conditional.)

Copy link
Contributor

Choose a reason for hiding this comment

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

@wjhsf The way devDependencies work in this repo is that they get inlined into the built dist files. So we use this as a kind of hack when we want certain built files not to contain any requires/imports.

In this case, I think this is the right approach since historically @lwc/engine-core doesn't have any imports. The question then is why not just make @lwc/signals part of @lwc/engine-core itself.

The answer to that, I think, is that @lwc/signals kind of acts as a documentation hub and an ergonomic way to import a base class that consumers can extend. The value of this does seem debatable to me (we could just export the same thing from @lwc/engine-dom/lwc), and historically we've annoyed users with a proliferation of @lwc/* repos they've had to install, and plus this is an experimental feature, so I would lean towards just moving it back to @lwc/engine-core for now.

</template>
```

## Supported APIs
Copy link
Contributor

Choose a reason for hiding this comment

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

How quickly is the code in the README gonna get out of date? 😅

Copy link
Contributor

Choose a reason for hiding this comment

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

This problem isn't unique to @lwc/signals. I think this is fine for now. Long term, I'd love for these sections of the README to be generated from comments + types of the top-level exposed functions/classes/etc.

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.

LGTM so far, just a few small nitpicks and some suggestions mostly about moving @lwc/signals code to @lwc/engine-core until we decide if we want it to become a real boy or not.

Exciting stuff!

"devDependencies": {
"observable-membrane": "2.0.0"
"observable-membrane": "2.0.0",
"@lwc/signals": "6.0.0"
Copy link
Contributor

Choose a reason for hiding this comment

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

@wjhsf The way devDependencies work in this repo is that they get inlined into the built dist files. So we use this as a kind of hack when we want certain built files not to contain any requires/imports.

In this case, I think this is the right approach since historically @lwc/engine-core doesn't have any imports. The question then is why not just make @lwc/signals part of @lwc/engine-core itself.

The answer to that, I think, is that @lwc/signals kind of acts as a documentation hub and an ergonomic way to import a base class that consumers can extend. The value of this does seem debatable to me (we could just export the same thing from @lwc/engine-dom/lwc), and historically we've annoyed users with a proliferation of @lwc/* repos they've had to install, and plus this is an experimental feature, so I would lean towards just moving it back to @lwc/engine-core for now.

target &&
typeof target === 'object' &&
'value' in target &&
'subscribe' in target &&
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
'subscribe' in target &&

No need since you're already checking it's a function below.

Copy link
Member Author

@jmsjtu jmsjtu Feb 6, 2024

Choose a reason for hiding this comment

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

@nolanlawson typescript is complaining when I do isFunction(target.subscribe), without checking for 'subscribe' in target so I added it back for now.

} catch (err) {
logWarnOnce(
`Attempted to subscribe to an object that has the shape of a signal but received the following error: ${err}`
);
Copy link
Contributor

Choose a reason for hiding this comment

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

We should probably have a call to action here, i.e. tell the user what they need to do to resolve the error.

AIUI, this will occur if e.g. a plain object has a shape like { value: 'foo' }, right? Meaning we mistakenly sniff it as a signal?

If that's the case, then I don't know if we even want to warn here. value is a pretty common name, and I imagine users may be annoyed by even one warning message, if there's nothing they can do about it. What do other signal-using frameworks do in this case?

Copy link
Contributor

Choose a reason for hiding this comment

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

We're checking typeof obj.subscribe === 'function' elsewhere, so that is not the case that'd be hit here. Instead, this would occur if an error threw while inside the subscribe() call.

We definitely don't want errors in subscribe() to be swallowed entirely. However, I think coercing into a string here might not be the right thing. For example, you'd lose the call stack. On the other hand, a thrown object isn't guaranteed to be an actual error object. So maybe something like this:

logWarnOnce(`Attempted to subscribe to...: ${err?.stack ?? err}`);

Copy link
Member Author

Choose a reason for hiding this comment

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

@nolanlawson the signal shape should be { value: 'foo' , subscribe: () => {} }.

I'll have to do some more research on how other frameworks handle this situation but I know most of them have their own signal implementations, so they may not run into this issue.

I'll log the stack trace for now as @divmain suggested and look into how other frameworks handle this in more detail.

} catch (err) {
logWarnOnce(
`Attempted to call a signal's unsubscribe callback but received the following error: ${err}`
);
Copy link
Contributor

Choose a reason for hiding this comment

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

This one seems a bit more serious to me. In this case, we might have landed here because a signal exposed a value and a subscribe, but not an unsubscribe. (I.e. you may have a signal implementation that is prone to memory leaks, since it doesn't implement unsubscribe.) I wonder if there is value in separating out the two cases and providing a better error message for each scenario.

Copy link
Contributor

Choose a reason for hiding this comment

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

Elsewhere, we are checking that typeof unsubscribe === 'function'. So we know at this point that unsubscribe is invokable. However, similar to the other condition, this could occur if an error occurs during unsubscribe, e.g. the signal wraps a websocket connection, unsubscribe() attempts to ws.close() and that fails for some reason.

Same ask as earlier for preserving the error's stack.

Copy link
Member Author

Choose a reason for hiding this comment

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

@nolanlawson In SignalTracker.subscribeToSignal we now check that the subscribe returns an unsubscribe function and only stores if it's a function.

For v1 I'll log the stack trace as @divmain suggested but I left a todo (#3978) to revisit this in 252 once we figure out the use cases and know how we want to better handle the errors.

Comment on lines +3 to +6
This is an experimental package containing the interface expected for signals.

A key point to note is that when a signal is both bound to an LWC class member variable and used on a template,
the LWC engine will attempt to subscribe a callback to rerender the template.
Copy link
Contributor

Choose a reason for hiding this comment

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

As mentioned above, I think for now this should probably just be in @lwc/engine-core. We can decide later if we want to expose a brand-new package out of this.

Copy link
Contributor

@divmain divmain left a comment

Choose a reason for hiding this comment

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

This is looking good. As soon as the minor comments/suggestions are addressed, I think this is ready to merge as our v1.

subscribeToSignal(signal: Signal<unknown>, update: CallbackFunction) {
try {
const unsubscribe = signal.subscribe(update);
if (typeof unsubscribe === 'function') {
Copy link
Contributor

Choose a reason for hiding this comment

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

Agree with the above suggested change. Also, the thing is not a signal if typeof subscribe() !== 'function'. The signal contract is being broken in this case. With the current implementation, that'd result in a silent failure. Long-term we may want to surface a warning or error for stronger enforcement. I'd suggest opening an issue to revisit this in 252 once we have a better idea of the best behavior to adopt here.

} catch (err) {
logWarnOnce(
`Attempted to subscribe to an object that has the shape of a signal but received the following error: ${err}`
);
Copy link
Contributor

Choose a reason for hiding this comment

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

We're checking typeof obj.subscribe === 'function' elsewhere, so that is not the case that'd be hit here. Instead, this would occur if an error threw while inside the subscribe() call.

We definitely don't want errors in subscribe() to be swallowed entirely. However, I think coercing into a string here might not be the right thing. For example, you'd lose the call stack. On the other hand, a thrown object isn't guaranteed to be an actual error object. So maybe something like this:

logWarnOnce(`Attempted to subscribe to...: ${err?.stack ?? err}`);

} catch (err) {
logWarnOnce(
`Attempted to call a signal's unsubscribe callback but received the following error: ${err}`
);
Copy link
Contributor

Choose a reason for hiding this comment

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

Elsewhere, we are checking that typeof unsubscribe === 'function'. So we know at this point that unsubscribe is invokable. However, similar to the other condition, this could occur if an error occurs during unsubscribe, e.g. the signal wraps a websocket connection, unsubscribe() attempts to ws.close() and that fails for some reason.

Same ask as earlier for preserving the error's stack.

@jmsjtu jmsjtu requested a review from nolanlawson February 6, 2024 22:28
- **`API_VERSION=<version>`:** API version to use when compiling.
- **`DISABLE_SYNTHETIC_SHADOW_SUPPORT_IN_COMPILER=1`:** Disable synthetic shadow in the compiler itself.
- **`DISABLE_STATIC_CONTENT_OPTIMIZATION=1`:** Disable static content optimization by setting `enableStaticContentOptimization` to `false`.
- **`ENABLE_EXPERIMENTAL_SIGNALS=1`:** Enables tests for experimental signals protocol.
Copy link
Contributor

Choose a reason for hiding this comment

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

It just occurred to me: we do not technically need a new Karma env var, since ENABLE_EXPERIMENTAL_SIGNALS is a runtime-only flag. So you could just set the flag at runtime during the Karma tests.

Or do you intend to make it a compiler flag too in the future?

Copy link
Member Author

Choose a reason for hiding this comment

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

TIL! No, I don't intend for there to be a compiler flag in the future.

I've removed it from the karma tests.

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.

LGTM, a few nits and one question. :shipit:

@jmsjtu jmsjtu merged commit 314ab66 into master Feb 7, 2024
@jmsjtu jmsjtu deleted the jtu/signals branch February 7, 2024 00:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants