Skip to content

Comments

feat(babel-plugin): dynamic components#3338

Merged
jmsjtu merged 16 commits intojtu/dynamic-componentsfrom
jtu/dynamic-components-babel
Mar 1, 2023
Merged

feat(babel-plugin): dynamic components#3338
jmsjtu merged 16 commits intojtu/dynamic-componentsfrom
jtu/dynamic-components-babel

Conversation

@jmsjtu
Copy link
Member

@jmsjtu jmsjtu commented Feb 9, 2023

Details

This PR implements the changes to the babel-plugin and runtime from the dynamic components RFC.

This is part 2 of 3 of the dynamic components implementation.

Part 1 (template compiler implementation) can be found here.
Part 3 (karma integration tests) will be created after parts 1 and 2 have been merged.

PR structure

This implementation is broken down into 3 parts to make it easier to review and will be merged into the jtu/dynamic-components branch.

The reason for merging into the jtu/dynamic-components branch first is to ensure a single atomic commit for the dynamic components feature and to facilitate a clean coordinated release to lwc-platform, as this is a breaking change for lwc-platform.

There are a large number of file changes in this PR, most of which come from updates to the unit tests.

I've grouped all of the unit tests under the following two commits, if you are reviewing this PR through the GitHub UI you can exclude these two commits:

ea52bae
a4699e1

This should reduce the number of files changed significantly.

Does this pull request introduce a breaking change?

  • ✅ No, it does introduce a breaking change.

Does this pull request introduce an observable change?

  • 🚨 Yes, it does not introduce an observable change.

These are the observable changes:

  1. The module resolver will now return a type field.
  2. The Id in the @lwc/rollup-plugin will include a alias specifier as a query string for module entries that use aliases.
  3. The registerComponent function will now register an additional field called sel.

GUS work item

W-12190514

// The only scenario where the n1.sel and n2.sel are different is when the constructor
// of a dynamic custom element has changed.
// ex: <lwc:component lwc:is={ctor}>
if (!isSameVnode(n1, n2) && !areDynamicVCustomElements(n1, n2)) {
Copy link
Member Author

@jmsjtu jmsjtu Feb 9, 2023

Choose a reason for hiding this comment

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

I thought about having the new dynamic component vnodes only go through the updateDynamicChildren path but I think that it can also fit into the updateStaticChildren path, where this will be invoked.

With the new dynamic components, the value of vnode.sel will change when the constructor changes meaning this condition will fail in debug mode.

However, the main reason behind allowing dynamic components to use the updateStaticChildren path is that although the value of sel is not the same on update, its position within the child array will not change.

Eventually, when the vnode reaches the patchCustomElement routine, it checks to see if the ctors are equivalent and unmounts/mounts them.

This path is triggered whenever the dynamic components constructor is changed (same path that the current lwc:dynamic follows):

<template>
    <lwc:component lwc:is={lazyCtor}></lwc:component>
</template>
import { LightningElement } from 'lwc'

export default class extends LightningElement {
    lazyCtor;
    async connectedCallback() {
        const { default: ctor } = await import('x/foo');
        lazyCtor = ctor;
    }
}

Copy link
Contributor

@nolanlawson nolanlawson Feb 15, 2023

Choose a reason for hiding this comment

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

It might be worth noting in a comment that we expect the node to be unmounted/remounted when the sel changes. Otherwise it might seem like the props from one component will be patched onto a different component, which is not at all what's happening here.

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 I updated the comment in the latest commit, let me know if it's clear enough.

Copy link
Member Author

Choose a reason for hiding this comment

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

@caridy would love to get your thoughts on this part of the diffing algo.

Copy link
Contributor

Choose a reason for hiding this comment

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

@jmsjtu Per existing doc, dynamic components should be considered as dynamic for the purposes of diffing. I found these two references

  1. https://github.com/salesforce/lwc/blob/v2.37.3/packages/%40lwc/engine-core/src/framework/api.ts#L572
  2. feat: dynamic imports (experimental flag) #1397 (comment)

const params = new URLSearchParams(query);
params.set('specifier', specifier);
return `${filename}?${params.toString()}`;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Are we going to have to make this change in lwc-platform, lwc-webpack-plugin, lwc-test, and LWR as well? Usually when we add new query params, we have to.

Copy link
Member Author

Choose a reason for hiding this comment

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

Looking through these repositories, it looks like lwc-webpack-plugin and lwc-test will need a similar update.

lwc-platform invokes the transformers directly and passes in the namespace / name that's provided as part of the bundle so there's no need for LWC to scan the file directory.

It looks like the LWR code just calls the rollup plugin directly, so they should be able to use the plugin as is.

@nolanlawson
Copy link
Contributor

These are the observable changes:

  1. The module resolver will now return a type field.
  2. The Id in the @lwc/rollup-plugin will include a alias specifier as a query string for module entries that use aliases.
  3. The registerComponent function will now register an additional field called sel.

None of these changes seem to be something that will break users in practice. These are all quasi-internal APIs that are mostly only used by us and certain sister repos.

Copy link
Contributor

@ravijayaramappa ravijayaramappa left a comment

Choose a reason for hiding this comment

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

minor questions, otherwise LGTM


function getComponentRegisteredName(t, state) {
const { namespace, name } = state.opts;
const componentName = namespace && name ? `${namespace}-${name}` : '';
Copy link
Contributor

Choose a reason for hiding this comment

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

Minor note: Should this default to something other than an empty string? My suggestion is based on two things

  1. The comment here that in the future an empty sel will throw. So namespace and name will both become required fields
  2. Within salesforce namespace is familiar field and platform users will know to use a default value like c. OTOH, OSS users will have to contemplate what the namespace value needs to be.

Having said that, we are in a tough spot here. We don't have anything reliable to use for the component name. node.id is not always present. May be make name a required field and default namespace to c or lwc?

Took a second look: per the @lwc/compiler README , name and namespace are required, but the fields in the TS definition TransformOptions are both optional. So my earlier suggesting of making it required will be a breaking change.

Copy link
Member Author

@jmsjtu jmsjtu Feb 23, 2023

Choose a reason for hiding this comment

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

Took a second look: per the @lwc/compiler README , name and namespace are required, but the fields in the TS definition TransformOptions are both optional. So my earlier suggesting of making it required will be a breaking change.

I agree this is a tricky one. I think in practice if making the namespace and name required breaks any OSS customers, their components would already have issues, as those fields are used to generate the style tokens.

The only reason we need the component's name right now is for dynamic components.

My thinking was that by defaulting to an empty string, we could avoid the breaking change and just prevent components that don't have a registered name from being dynamically created.

The component author will have to correct the namespace and name if they want to dynamically create the component (this is most likely only an issue for OSS).

How would you feel if we left it as an empty string for now and make the namespace and name required once versioning comes out or in LWC v3?

This would line up the compiler flags with the description in the README and we wouldn't have to worry about defaulting to anything.

}

return null;
const sel = getComponentRegisteredName(Ctor);
Copy link
Contributor

Choose a reason for hiding this comment

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

For my understanding: The sel needs to be looked up from the Ctor here, at runtime, because the sel depends on the Ctor's metadata(name & namespace) and the template-compiler does not have this info ahead of time. Add a comment to that effect 🙂

Copy link
Member Author

Choose a reason for hiding this comment

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

Good call, added some comments. Let me know if it's clear enough.

// The only scenario where the n1.sel and n2.sel are different is when the constructor
// of a dynamic custom element has changed.
// ex: <lwc:component lwc:is={ctor}>
if (!isSameVnode(n1, n2) && !areDynamicVCustomElements(n1, n2)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

@jmsjtu Per existing doc, dynamic components should be considered as dynamic for the purposes of diffing. I found these two references

  1. https://github.com/salesforce/lwc/blob/v2.37.3/packages/%40lwc/engine-core/src/framework/api.ts#L572
  2. feat: dynamic imports (experimental flag) #1397 (comment)

Comment on lines 979 to 993
if (
n1.type === VNodeType.CustomElement &&
n2.type === VNodeType.CustomElement
) {
if (n1.ctor !== n2.ctor) {
// If the constructors differ, unmount the current component and mount a new one using
// the new constructor. This scenario only occurs for dynamic components.
const dynElmRenderer = n2.data.renderer ?? renderer;
const dynElmAnchor = dynElmRenderer.nextSibling(n1.elm);

unmount(n1, parent, dynElmRenderer, true);
mountCustomElement(n2, parent, dynElmAnchor, dynElmRenderer);
}
}
}
Copy link
Member Author

@jmsjtu jmsjtu Feb 25, 2023

Choose a reason for hiding this comment

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

This logic comes straight from patchCustomElements and is the logic that runs in the current diffing algo logic for dynamic components.

This logic was needed for lwc:dynamic because in the dynamic diffing algo the checks for isSameVnode would always trigger true. Thus if a new constructor was supplied, it would run patchCustomElement and need to unmount and mount the new component using the same tag name.

In the new implementation, isSameVnode will never be true when the dynamic component changes (the value of sel will be different).

Additionally, with the changes made in #2966 the dynamic components can now travel through the static path. This is allowed because we now consider the dynamic component before and after the constructor is changed to be equivalent.

Having said that, I've moved this logic out of patchCustomElement into updateStaticChildren because this path is still triggered when the constructor changes for a dynamic component. With the new implementation, when the constructors change, the dynamic components vnodes should still be considered equivalent (as they are in lwc:dynamic).

The logic is not needed in patchCustomElement anymore because the part of the dynamic diffing algo that relies on isSameVnode will not trigger true when the dynamic component's constructor changes in the new implmentation.

An alternative approach could be to emit a fragment for each dynamic component but I chose to avoid this because each fragment is wrapped in empty text nodes. I felt that the empty text nodes were unnecessary in this case since there will only ever be a single node.

Copy link
Member Author

@jmsjtu jmsjtu Feb 25, 2023

Choose a reason for hiding this comment

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

@pmdartus @nolanlawson @ravijayaramappa would love to get your thoughts on the ☝️ if you get a chance 😃

Copy link
Contributor

Choose a reason for hiding this comment

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

The logic is not needed in patchCustomElement anymore because the part of the dynamic diffing algo that relies on isSameVnode will not trigger true when the dynamic component's constructor changes in the new implmentation.

I don't fully understand what you mean by the "the part of the dynamic diffing algo that relies on isSameVnode".

Overall, I don't think the dynamic component diffing logic belongs to the updateStaticChildren. How custom elements are patched should be an internal detail of the patchCustomElement.

Copy link
Contributor

Choose a reason for hiding this comment

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

Overall, I don't think the dynamic component diffing logic belongs to the updateStaticChildren. How custom elements are patched should be an internal detail of the patchCustomElement.

My reading is that this isn't really about dynamic components per se – it's just that the diffing algo never had to deal with diffing two nodes with different tagNames at this point in the code before.

Maybe the question is: Why is this code even needed?

                        if (
                            n1.type === VNodeType.CustomElement &&
                            n2.type === VNodeType.CustomElement
                        )

I.e. why check that they are both custom elements? If one is a div and the other is an input, we still want to completely unmount one and mount the other one. We do need to check if the second is a custom element, though, so we can call mountCustomElement instead of mountElement.

Copy link
Member Author

@jmsjtu jmsjtu Feb 27, 2023

Choose a reason for hiding this comment

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

I don't fully understand what you mean by the "the part of the dynamic diffing algo that relies on isSameVnode".

@pmdartus I was referring to the parts of updateDynamicChildren where isSameVnode is invoked, it's possible for the dynamic component (using the implementation for lwc:dynamic) to enter this path and traverse through patchCustomElement when the constructor changes.

This would cause the component to be recreated inside patchCustomElement because although the constructor changes the tag name will be the same (isSameVnode check passes).

What I was referring to is, in the new implementation isSameVnode won't pass the check when the constructor changes because the tag name will be different and therefore the only reason patchCustomElement has the logic for unmount / mounting is to be used with updateStaticChildren.

Here's an example using lwc:dynamic vs lwc:is.

My reading is that this isn't really about dynamic components per se – it's just that the diffing algo never had to deal with diffing two nodes with different tagNames at this point in the code before.

Yup, the reason I moved this logic out of the patchCustomElements routine is that the updateStaticChildren algo has never needed to diff against nodes when the tag names are different.

Maybe the question is: Why is this code even needed?
I.e. why check that they are both custom elements? If one is a div and the other is an input, we still want to completely unmount one and mount the other one. We do need to check if the second is a custom element, though, so we can call mountCustomElement instead of mountElement.

Originally I moved this out of patchCustomElements because I didn't see a need for it to do the unmounting / mounting logic when it could be done here. The reason being that updateDynamicChildren will not invoke patchCustomElements and enter the code path to unmount / mount the constructor in the new implementation.

This leaves only updateStaticChildren that would need to do the unmounting / mounting. However, now that I take a second look at updateStaticChildren, it doesn't have any specifics about a type of vnode, but works with vnodes in general. The logic for specifically handling vnodes types (VCustomElement, VElement, etc.) is done in patch.

I decided to move the logic back to patch for this reason. Although, I think in the future if we find a need to diff elements against tag names that have changed, we may want to update the rest of the patch methods to account for this.

Right now all the other patch functions only apply the attributes and props to their respective elements while patchCustomElement will also unmount / mount the element.

I've also updated the dev error here since patching a dynamic components no longer have the same tag names.

Copy link
Member Author

@jmsjtu jmsjtu Feb 28, 2023

Choose a reason for hiding this comment

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

why check that they are both custom elements? If one is a div and the other is an input, we still want to completely unmount one and mount the other one.

@nolanlawson based on your feedback I updated the unmounting / mounting logic in updateStaticChildren to be agnostic of the type that is a mismatch.

With this implementation, patch now serves the sole purpose of only updating the same element.

Let me know what you think, I'm not sure if this is a breaking change or not. My thoughts are that it shouldn't be.

Copy link
Contributor

Choose a reason for hiding this comment

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

If the tests still pass, then it's probably not a breaking change. Nucleus also tends to catch diffing algo changes that cause downstream breakages.

Copy link
Member Author

Choose a reason for hiding this comment

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

Based on the review this morning, the team decided to isolate the changes in unmounting / mounting of the dynamic component to patchCustomElement.

In a future PR, we will introduce the unmounting / mounting in updateStaticChildren to help isolate any potential future regression.

@jmsjtu
Copy link
Member Author

jmsjtu commented Feb 25, 2023

Per existing doc, dynamic components should be considered as dynamic for the purposes of diffing. I found these two references

@ravijayaramappa I don't think this is a strict requirement anymore after #2966.

I left a comment here explaining the details, let me know if it's clear.

@jmsjtu jmsjtu requested a review from nolanlawson February 25, 2023 06:40
Comment on lines 979 to 993
if (
n1.type === VNodeType.CustomElement &&
n2.type === VNodeType.CustomElement
) {
if (n1.ctor !== n2.ctor) {
// If the constructors differ, unmount the current component and mount a new one using
// the new constructor. This scenario only occurs for dynamic components.
const dynElmRenderer = n2.data.renderer ?? renderer;
const dynElmAnchor = dynElmRenderer.nextSibling(n1.elm);

unmount(n1, parent, dynElmRenderer, true);
mountCustomElement(n2, parent, dynElmAnchor, dynElmRenderer);
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

The logic is not needed in patchCustomElement anymore because the part of the dynamic diffing algo that relies on isSameVnode will not trigger true when the dynamic component's constructor changes in the new implmentation.

I don't fully understand what you mean by the "the part of the dynamic diffing algo that relies on isSameVnode".

Overall, I don't think the dynamic component diffing logic belongs to the updateStaticChildren. How custom elements are patched should be an internal detail of the patchCustomElement.

Comment on lines 94 to 99
// Currently the only scenario when patch does not receive the same vnodes are for
// dynamic components. This is why the final check here ensures n2.type is a custom element.
// When a dynamic component's constructor changes, the value of its tag name (sel) will be
// different. The engine will unmount the previous element and mount the new one using the
// new constructor in patchCustomElement.
n2.type !== VNodeType.CustomElement
Copy link
Member Author

@jmsjtu jmsjtu Feb 27, 2023

Choose a reason for hiding this comment

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

I tried to make this comment as clear as possible but what I was going for was, we have a way to handle when the vnodes are not the same (different sel and key) for custom elements. We don't for the other types so we throw an error on those ones.

Copy link
Contributor

Choose a reason for hiding this comment

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

In this case, don't we also expect n1.type to be VNodeType.CustomElement?

Copy link
Member Author

@jmsjtu jmsjtu Feb 28, 2023

Choose a reason for hiding this comment

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

Ah you're right, I was thinking it would be enough to only check that n2.type is a custom element because it's what's changing and we'd be replacing it in patchCustomElement. But if n1.type is not a custom element, patchCustomElement will break as well.

I've updated the validation logic in the latest commit.

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've updated the logic so this should no longer be needed, see my previous comment.

if (type === RegistryType.alias) {
// specifier must be in in namespace/name format
const hasValidSpecifier = specifier.split('/').length > 1;
const hasValidSpecifier = specifier.split('/').length === 2;
Copy link
Contributor

Choose a reason for hiding this comment

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

This doesn't handle strings like bar/ and /foo correct? Their length would be 2, even though they're invalid.

Copy link
Member Author

@jmsjtu jmsjtu Feb 28, 2023

Choose a reason for hiding this comment

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

Sorry about that I missed the previous comment. I've updated the logic to account for variations of invalid alias specifiers.

Also want to note that a relative import (starting with a './' or '/' is not subject to the aliasing, as they would be using the actual file path, ie ./foo.js which does not use an alias.

Here's the updated logic + unit tests.

@jmsjtu jmsjtu merged commit 44fe1b6 into jtu/dynamic-components Mar 1, 2023
@jmsjtu jmsjtu deleted the jtu/dynamic-components-babel branch March 1, 2023 04:16
@jmsjtu jmsjtu mentioned this pull request Mar 3, 2023
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