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

feat(engine-core): introducing DynamicLightningElement #3204

Closed
wants to merge 2 commits into from

Conversation

caridy
Copy link
Contributor

@caridy caridy commented Dec 2, 2022

Details

POC on how can we create a magical <dynamic-lightning-element lwc:dynamic={ctor}> that accepts a ctor property to be passed in with an arbitrary LightningElement constructor. This implements dynamic elements in templates without the issues of previous implementations that were attempting to reuse the name of the element while replacing the custom element associated to it.

How do it work?

This new element, that is NOT an abstract class, but it is not final either, can be extended if needed. This element can be used in templates (compiler will know how to resolve it). Once the element is created (via the diffing algo), it will expect a property (ctor) to be provided... if provided, it should be a qualifying LightningElement, and a definition will be procured in the process.

Then the already created VM, and the already created and linked element, can be used to reset the state of the VM, replacing the associated definition, and boot up the component again by creating a new instance of it. The old instance will be collected by the GC, and the new instance will take control over the element. This process can be repeated as many times as you want.

Few other considerations:

  1. The compiler can remove the element via diffing algo (vnode to be removed) if the ctor is not qualifying, or is pending (promise) to be resolved.
  2. Any expando, or work on the actual element instance (which are rare) will be preserved even after replacing the constructor associated to it since the element instance is reused, only the component (cmp) is replaced.
  3. The VM needs to be refreshed (reset), or at least parts of it to avoid creating a new VM all together. This should be fine since this is a controlled process.
  4. The compiler can also trigger the reset of the properties (or spread) right after setting the ctor property (if we can guarantee that the ctor prop goes first, then we can just set the others right after). This is important... even if the old props are the same. Attributes on the other hand, don't need to be reset. Finally, listeners could be tricky.
  5. If you want to observe the replacement process in user-land, you can rely on a new callback called replacedCallback, which give us a hook into the replacing process to accommodate the new instance.

Will this work when using web components?

Yes, <dynamic-lightning-element> can be registered as web components, as well as subclasses of it. This will work fine, of course, a constructor cannot be set from HTML, meaning only the property can be set, without reflection into the ctor attribute, instead we use the magical lwc:dynamic directive, which also allows the compiler to identify that this component is actually a dynamic component that needs special handling. Now, the ctor value can only be a LightningElement, attempting to pass a CustomElement Constructor will throw a type error.

What other work is needed for this to function?

The compiler work is pending. <dynamic-lightning-element> must be recognized by the parser, and the resolution for the constructor must be the internal DynamicLightingElement, which is exported from LWC the same way LightningElement is. The work of the compiler is very similar to what we do today with lwc:dynamic directive. The difference is that promises must be controlled by the output code, or a method in api.ts (that's TBD).

How to transition from the current implementation into this?

Additionally, we should be able to transform existing lwc:dynamic syntax into the new syntax to avoid breaking existing customers. We can make it very easy by allowing other tagNames with lwc:dynamic, to inherit from dynamic-lightning-element, and that way, they can continue using the same syntax. Remember that only a very small group of people are allowed to use this. Eventually, a lot of code can be removed to eliminate the lwc:dynamic directive all together from compiler and runtime.

Does this pull request introduce a breaking change?

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

Does this pull request introduce an observable change?

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

GUS work item

TBD

@@ -269,6 +269,19 @@ function getNearestShadowAncestor(vm: VM): VM | null {
return ancestor;
}

export function createVMContext() {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

this is just abstracting a segment of the vm creation process so we can create more in dynamic.

Note: I'm not 100% sure if the ctx is cached somewhere, I believe it is only used inside templates, in which case it doesn't matter since the template is algo going to be replaced.

Note 2: what happen if you swap ctors, but both resolves to the same template? jejejeje

}
}

registerComponent(DynamicLightingElement, { tmpl: defaultEmptyTemplate });
Copy link
Contributor Author

Choose a reason for hiding this comment

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

this is needed since we are not going to compile this class via our compiler.

note: do we need to mark ctor as public API? I don't think we need to since it will not be set via the regular diffing algo, but with some special mechanism.

note 2: what about IE11/compat? will this class definition work? I believe it does, we only have some prior art in this package in the reactivity class definition.

// and it is not configurable to avoid something messing with its public API. The
// fact that it is installed on instance guarantees that changing the proto-chain
// will not affect its behavior.
defineProperty(getVMFromComponent(this).elm, 'ctor', {
Copy link
Contributor Author

@caridy caridy Dec 2, 2022

Choose a reason for hiding this comment

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

this is the most important part, read the comment.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Does this need to be a public property? Could we use a WeakMap instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

it can't be a weakmap if we want to allow DynamicLightningElement.CustomElementConstructor to be useful. Basically, if it is a weakmap, then a public API will have to be exposed from LWC so people can set the replace in motion by calling that API with an element and the new ctor, and that api will do what we do here... but then exporting a LWC as web components makes it a lot less useful. Another option is to make it a method rather than a setter, which I'm fine with as well.


// validation of the mode... it must match the mode of the original implementation
// users can extend DynamicLightingElement to adjust the mode at will.
// do we need the same thing for cm.renderMode? I believe we do!
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'm not sure about renderMode.

@abdulsattar
Copy link
Contributor

Can you explain when users might want to extend DynamicLightningElement?

replacedCallback(): void {
// this is useful for subclasses to get a notification every time the
// component is swapped after setting `ctor` property.
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

If this is an empty function, does it need to be defined? Unless we're worried that subclasses will get an error if they do super.replacedCallback() inside of their own replacedCallback.

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 think you're right... it could be undefined... and put an if condition around the calls to it. I believe connectedCallback and co. are always defined in LightningElement though, IIRC.

Copy link
Collaborator

Choose a reason for hiding this comment

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

They are not. Calling super.connectedCallback() throws an error.

Copy link
Collaborator

Choose a reason for hiding this comment

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

In a sense, replacedCallback can be seen as a special case of "disconnectedCallback followed by connectedCallback." Is it necessary to even have replacedCallback, given that developers can use those events?

Arguably, developers should be handling "disconnectedCallback followed by connectedCallback" anyway (although I wouldn't be surprised if they're not – web component authors often don't expect their component to be removed and then re-added).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ok, I'm fine making this undefined.

As for the second part of your comment, the replacedCallback is not for the author of the component being replaced, it is for the consumer to know that something on its template is being replaced... and this is only there because of the promises.


function replaceDefinitionForVM(vm: VM, newDef: ComponentDef, replacedCallback: () => void) {
if (vm.state === VMState.connected) {
runDisconnectedCallback(vm);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Assuming we enable native connected/disconnected callback events (#3198), this would be the one place where we are simulating our own disconnected callback behavior. This introduces unknown unknowns – e.g. subtle bugs where we incorrectly detect connectedness in templates (like #2609 (comment)).

Are we sure we want to add another source of synthetic lifecycle callbacks? Is there some other way we can achieve this? (E.g. explicitly removing and re-adding the DOM node.)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmm, I don't understand this part. How is this different from rehydration from SSR?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Rehydration is another case where we manually invoke connected callback, you're right. Although I'm not sure we will always do it that way. /cc @divmain

In Lit for example I believe they use the native connectedCallback, but they queue up the callbacks in SSR so that they fire in a predictable order. (Otherwise they will fire based on when customElements.define() is called, which is kind of random and unpredictable.

Copy link
Contributor

Choose a reason for hiding this comment

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

A compelling reason hasn't surfaced yet to move us away from manually invoking connectedCallback during rehydration. Lit uses the pattern you mention, as well as adding/removing ephemeral attrs (with corresponding event listeners) to enforce deterministic order of execution through a component hierarchy. The reason we were originally considered moving in this direction has gone away, now that we've decided to lock down DOM APIs on the host element.

Copy link
Collaborator

Choose a reason for hiding this comment

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

The reason we were originally considered moving in this direction has gone away, now that we've decided to lock down DOM APIs on the host element.

Can you elaborate on this? You mean moving away in terms of hydration? (Because I'm still hoping to get #3198 in, at least for the non-hydration scenario.)

Copy link
Collaborator

@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.

I'm a bit concerned about this approach. A few downsides I can think of:

  1. We will need a "magical" <dynamic-lightning-element> element that is treated specially by the parser. We don't have a precedent for this kind of thing in LWC yet.
  2. This adds a lot of dynamism to the existing linking between the element the VM. We can no longer assume the VM is read-only – instead, it might be mutated at any time.
  3. We are getting into the business of simulating connectedCallback and disconnectedCallback even though the element is not truly mounting/unmounting. (Yes we already do this in hydration, but why do it in more places?)
  4. We're adding new API surface in replacedCallback.

To me, the alternative approach of using a <template> with a directive that merely removes the old element from the DOM and inserts the new element (using standard VDOM operations) is much simpler conceptually and technically. No need for a magical tag name, no need to make VM mutable, no need to simulate connectedCallback/disconnectedCallback, and no need to implement replacedCallback (unless we really like it as a convenience thing). And it also resolves the issue of sharing tag names between implementations, because the tag name simply won't be shared (i.e. no need for any kind of "pivot").

@caridy
Copy link
Contributor Author

caridy commented Dec 9, 2022

@nolanlawson

  1. We will need a "magical" <dynamic-lightning-element> element that is treated specially by the parser. We don't have a precedent for this kind of thing in LWC yet.

No, it is not the name what matters, because you can extend that and call it whatever you want... it is the lwc:dynamic directive the one that ticks the parser to do the additional work to set the ctor property which might be a promise.

  1. This adds a lot of dynamism to the existing linking between the element the VM. We can no longer assume the VM is read-only – instead, it might be mutated at any time.

Well... yes... although the vm is still 1-1 to the element. It is still 1-1-1 vm-component-element with the difference that the component vector is replacable. Not sure what's wrong with that since we don't cache many things by component.

  1. We are getting into the business of simulating connectedCallback and disconnectedCallback even though the element is not truly mounting/unmounting. (Yes we already do this in hydration, but why do it in more places?)

Why do you think this is important? It is not really about rehydration, it is the fact that you need an elm to be able to create a component instance, whatever the connectedness of that elm is, that process should work the same way. This is analogous to definition of a name after elements are part of the DOM, which means they get upgraded.

  1. We're adding new API surface in replacedCallback.

We can remove it... if we feel that's not the right thing to do... prior art is errorCallback.

To me, the alternative approach of using a <template> with a directive that merely removes the old element from the DOM and inserts the new element (using standard VDOM operations) is much simpler conceptually and technically. No need for a magical tag name, no need to make VM mutable, no need to simulate connectedCallback/disconnectedCallback, and no need to implement replacedCallback (unless we really like it as a convenience thing). And it also resolves the issue of sharing tag names between implementations, because the tag name simply won't be shared (i.e. no need for any kind of "pivot").

Let's do an actual comparison, because I will probably disagree with many of the things that you're listing there.

@nolanlawson
Copy link
Collaborator

nolanlawson commented Dec 9, 2022

@caridy

No, it is not the name what matters, because you can extend that and call it whatever you want...

OK that's fair, I thought <dynamic-lightning-element> was special somehow.

Not sure what's wrong with that since we don't cache many things by component.

It just feels error-prone for us to share the VM state across two completely different components... e.g. are we sure we're doing the garbage collection correctly and cleaning up styles when we unmount+mount? Whereas we already have a well-worn path for doing an element unmount+mount.

Why do you think this is important?

The current synthetic connectedCallback/disconnectedCallback system is a bug factory (#3198) with a lot of tricky edge cases that we don't handle correctly. (E.g. this fun one with <template>s.) I just get nervous when we start talking about simulating native browser behavior, especially when (again) we already have a well-worn path for doing unmount+mount.

Let's do an actual comparison

Agreed, let's do a bake-off. I suspect the other implementation is cleaner, but who knows. 🙂

@jmsjtu
Copy link
Member

jmsjtu commented Dec 12, 2022

@caridy @nolanlawson - I did a POC (#3218) for how we can use lwc:dynamic on a <template>, let me know what you think.

@nolanlawson
Copy link
Collaborator

This design has been dropped in favor of the new lwc:dynamic proposal, so I think we can close this PR.

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.

5 participants