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(template-compiler): implement handling for scoped slot directives in template #3077

Merged
merged 24 commits into from
Oct 14, 2022

Conversation

ravijayaramappa
Copy link
Contributor

@ravijayaramappa ravijayaramappa commented Oct 2, 2022

Details

This PR introduces handling for the two scoped slot directives: lwc:slot-bind and lwc:slot-data.

  • Added two AST nodes to handle the new directives. Added a ScopedSlotContent to represent the template content provided by the parent component.
  • Added code gen parts for the ScopedSlotContent to generate a VScopedSlotContent
  • The feature is guarded by a config which is currently default off.

Follow up PRs:

  • Handle slotting at runtime
  • Use VFragment to represent return value of the ScopedSlotFactory

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

W-11469044

@ravijayaramappa ravijayaramappa marked this pull request as draft October 3, 2022 18:09
@salesforce-nucleus
Copy link
Contributor

❌ An unexpected error occurred while attempting to start the test stage of workflow 94079. Please try to start the stage again, or reach out to #nucleus-talk for help.

@salesforce-nucleus
Copy link
Contributor

❌ An unexpected error occurred while attempting to start the test stage of workflow 94098. Please try to start the stage again, or reach out to #nucleus-talk for help.

@ravijayaramappa ravijayaramappa marked this pull request as ready for review October 6, 2022 21:52
@ravijayaramappa ravijayaramappa changed the title feat(template-compiler): implement handling for scoped slot directives in template - WIP feat(template-compiler): implement handling for scoped slot directives in template Oct 6, 2022
@@ -246,7 +246,7 @@ function hydrateCustomElement(
vnode.elm = elm;
vnode.vm = vm;

allocateChildren(vnode, vm);
allocateChildren(vnode, vm, owner);

This comment was marked as resolved.

This comment was marked as resolved.

@@ -473,33 +475,38 @@ export const ParserDiagnostics = {
level: DiagnosticLevel.Error,
url: '',
},

This comment was marked as resolved.

This comment was marked as resolved.

// Evaluate in the scope of the slot content's owner
setVMBeingRendered(slotset.owner);
}
children = vnode.factory(data.slotData);

This comment was marked as resolved.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Working on a separate PR for testing reactivity #3105

if (isVBaseElement(vnode)) {
slotName = (vnode.data.attrs?.slot as string) || '';
if (isVBaseElement(vnode) && !isUndefined(vnode.data.attrs?.slot)) {
slotName = vnode.data.attrs?.slot as string;

This comment was marked as resolved.

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.

First pass. LGTM but I am not super familiar with the parser and don't feel comfortable +1'ing yet.

Comment on lines 197 to 200
if (!isUndefined(slotset.owner)) {
// Evaluate in the scope of the slot content's owner
setVMBeingRendered(slotset.owner);
}

This comment was marked as resolved.

} finally {
setVMBeingRendered(vmBeingRenderedInception);
}
return acc.concat(children);

This comment was marked as resolved.

return acc.concat(children);
} else {
// If the slot content is a static list of child nodes provided by the parent, nothing to do
return acc.concat(vnode);

This comment was marked as resolved.

@@ -35,6 +43,13 @@ export interface BaseVNode {
owner: VM;
}

export interface VScopedSlotContent extends BaseVNode {
// TODO [#9999]: should the factory return a VFragment instead?
Copy link
Collaborator

Choose a reason for hiding this comment

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

9999? 🙃

} = scopedSlotContent;
codeGen.beginScope();
codeGen.declareIdentifier(dataIdentifier);
// TODO [#9999]: Next Step: Return a VFragment

This comment was marked as resolved.

This comment was marked as resolved.

parent: ParentNode,
parse5Elm: parse5.Element
): ScopedSlotContent | undefined {
const slotDataAttr = parsedAttr.pick('lwc:slot-data');

This comment was marked as resolved.

packages/@lwc/template-compiler/src/parser/index.ts Outdated Show resolved Hide resolved
@@ -2,7 +2,7 @@
"files": [
{
"path": "packages/lwc/dist/engine-dom/umd/es2017/engine-dom.min.js",
"maxSize": "18.5KB",
"maxSize": "18.6KB",

This comment was marked as resolved.

@@ -394,7 +395,8 @@ export const ParserDiagnostics = {

SLOT_TAG_CANNOT_HAVE_DIRECTIVES: {
code: 1082,
message: "Slot tag can't be associated with directives",
message:
"Slot tag can't be associated with for:each, for:of, if:true, if:flase, lwc:if, lwc:else, lwc:elseif template directives.",

This comment was marked as resolved.

This comment was marked as resolved.

@@ -252,7 +265,8 @@ function parseElementDirectives(
ctx: ParserCtx,
parsedAttr: ParsedAttribute,
parent: ParentNode,
parse5ElmLocation: parse5.ElementLocation
parse5ElmLocation: parse5.ElementLocation,
Copy link
Member

Choose a reason for hiding this comment

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

Since we're passing the entire parse5.Element, we can skip passing the parse5.ElementLocation here and pass the needed values to the parsers.

Maybe something like this?

const { tagName, ElementLocation: { parse5ElmLocation } } = parse5Elm;

....

const node = parser(ctx, parsedAttr, parse5ElmLocation, prev, tagName);

Copy link
Contributor Author

@ravijayaramappa ravijayaramappa Oct 11, 2022

Choose a reason for hiding this comment

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

There is some processing to obtain parse5ElmLocation in parseElement

const parse5ElmLocation = parseElementLocation(ctx, parse5Elm, parse5ParentLocation);

const parse5ElmLocation = parseElementLocation(ctx, parse5Elm, parse5ParentLocation);

This location is used in multiple places in parseElement. Passing the parse5ElmLocation to parseElementDirectives helps reuse.

Copy link
Member

Choose a reason for hiding this comment

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

I am also on the side of @jmsjtu. I had to get back into the code to better understand why we don't access the elementLocation property directly on the element.

In a follow-up PR, it might be interesting to normalize the parse5Elm and fix the location attribute in place in parseElement to avoid having to carry along this parse5Location 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.

Filed #3108

packages/@lwc/template-compiler/src/parser/index.ts Outdated Show resolved Hide resolved
Copy link
Contributor

@jye-sf jye-sf left a comment

Choose a reason for hiding this comment

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

First pass with some questions.

ctx.warnAtLocation(ParserDiagnostics.NO_SLOTS_IN_ITERATOR, location, [
name === '' ? 'default' : `name="${name}"`,
]);
}

if (isScopedSlot) {
ctx.seenScopedSlots.add(name);
Copy link
Contributor

Choose a reason for hiding this comment

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

One feature of if/elseif/else is that slots in mutually exclusive branches of a conditional tree are allowed to have the same name. Is that something that should hold true for scoped slots as well, or is this intentionally staying as a global store?

Sample Possible Use Case

<template lwc:render-mode="light">
    <template lwc:if={showStandard}>
        <slot name="slotname1" lwc:slot-bind={slot1StandardData}></slot>
    </template>
    <template lwc:elseif={showVariant}>
        <slot name="slotname1" lwc:slot-bind={slot1VariantData}></slot>
    </template>
    <template lwc:else>
        <slot lwc:slot-bind={defaultdata}></slot>
    </template>
</template>

Copy link
Contributor

Choose a reason for hiding this comment

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

Note that since the alreadySeen logic above reuses the standard slot name detection, the code in lines 1218 - 1219 will return false for alreadySeen when parsing the second slotname1 slot in the above example and no error will be thrown. We'll just be adding the name to the seenScopedSlots set twice.

With the way seenScopedSlots is currently used, the code should work fine, but the parser is in an interesting brittle state when we parse the second slot in the above example: alreadySeen === false, because the other slot is in a separate conditional branch and isn't relevant to the current context, while ctx.seenScopedSlots.has(name) === true, because we don't make this distinction when managing the seenScopedSlots Set.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@jye-sf
I thought about this and added some test cases to come with this opinion.

  1. Basically, this.seenScopedSlots does not need to be if-block aware. The detection logic is handled by ctx.hasSeenSlot. The look up on ctx.seenScopedSlots is to just augment that information to produce an appropriate error/warning message.

  2. However, it can be made context aware by replicating the same was slot names are tracked. The scoped slots set will be a subset of the slot set and it does not bring much more else. I didn't see much value in doing that.

Let me know if 1 is okay or if you strongly prefer 2.

Copy link
Contributor

Choose a reason for hiding this comment

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

I am okay with 1. It makes sense to not want to add a bunch of unnecessary duplicate code.

Maybe we can just rename alreadySeen to seenInContext or something like that; that might partially address the potential brittleness.

@@ -44,6 +44,10 @@ import {
SpreadDirective,
ElementDirective,
RootDirective,
SlotBindDirective,
/* SlotDataDirective, */

This comment was marked as resolved.

@@ -356,6 +358,10 @@ export default class CodeGen {
);
}

getScopedSlotFactory(callback: t.FunctionExpression, slotName: t.SimpleLiteral) {

This comment was marked as resolved.

Copy link
Member

@pmdartus pmdartus left a comment

Choose a reason for hiding this comment

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

Here is a first pass on the types and tests only.

packages/@lwc/template-compiler/src/shared/types.ts Outdated Show resolved Hide resolved
packages/@lwc/template-compiler/src/shared/types.ts Outdated Show resolved Hide resolved
packages/@lwc/template-compiler/src/shared/types.ts Outdated Show resolved Hide resolved
Comment on lines +137 to +138
| SlotBindDirective
| SlotDataDirective
Copy link
Member

Choose a reason for hiding this comment

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

This should be a lot more restrictive. The SlotBindDirective could only be present on a Slot element, while the SlotDataDirective can only be applied to ScopedSlotContent.

I would remove the SlotBindDirective from this list and add it specifically to the Slot node (if possible). Regarding the ScopedSlotContent directive, there is something wrong in this PR, see comment below.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Tried to do it, but it creates branching logic in downstream consumers of element.directives, for example renderer-hooks.ts:checkElement and codegen/index.ts:elementDataBag. They have to add a special branch for <slot> elements.

Currently the lwc:slot-bind directive is restricted to only <slot> elements in applyLwcSlotBindDirective() routine.

@@ -0,0 +1,15 @@
<template>
<x-child> <!-- no `lwc:slot-data` on the component tag -->

This comment was marked as resolved.

Comment on lines +2 to +7
<template lwc:if={showStandard}>
<slot name="slotname1"></slot>
</template>
<template lwc:elseif={showVariant}>
<slot name="slotname1" lwc:slot-bind={slot1VariantData}></slot>
</template>
Copy link
Member

Choose a reason for hiding this comment

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

How does this component work at runtime? I do see how a consumer could use this component.

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 slot content type(standard v/s scoped) has to be in sync with the child's slots. Unfortunately, this cannot be checked at compile time. So at runtime, the combinations are as follows:

  1. child and parent use same type of slot: happy path, works fine.
  2. child and parent have mixed slot type: log an error in non-prod mode, degrade gracefully and revert to fallback content

Will add tests cases and handling for 2 in followup PR(as part of W-11838547)

packages/@lwc/template-compiler/src/shared/types.ts Outdated Show resolved Hide resolved
packages/@lwc/template-compiler/src/parser/index.ts Outdated Show resolved Hide resolved
packages/@lwc/template-compiler/src/parser/index.ts Outdated Show resolved Hide resolved
packages/@lwc/template-compiler/src/parser/index.ts Outdated Show resolved Hide resolved
@@ -252,7 +265,8 @@ function parseElementDirectives(
ctx: ParserCtx,
parsedAttr: ParsedAttribute,
parent: ParentNode,
parse5ElmLocation: parse5.ElementLocation
parse5ElmLocation: parse5.ElementLocation,
Copy link
Member

Choose a reason for hiding this comment

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

I am also on the side of @jmsjtu. I had to get back into the code to better understand why we don't access the elementLocation property directly on the element.

In a follow-up PR, it might be interesting to normalize the parse5Elm and fix the location attribute in place in parseElement to avoid having to carry along this parse5Location object.

packages/@lwc/template-compiler/src/parser/index.ts Outdated Show resolved Hide resolved
} = scopedSlotContent;
codeGen.beginScope();
codeGen.declareIdentifier(dataIdentifier);
// TODO [#9999]: Next Step: Return a VFragment

This comment was marked as resolved.

packages/@lwc/engine-core/src/framework/api.ts Outdated Show resolved Hide resolved
[key: string]: VNodes;
// Slot assignments by name
slotAssignments: { [key: string]: VNodes };
owner?: VM;
Copy link
Member

Choose a reason for hiding this comment

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

Why isn't the owner property required? All the VNodes associated with the slotAssignments has to have an owner.

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 root vm will not have an owner for its slotset(its slotAssignments are also empty). One option is to make VM['cmpSlots'] an optional field. It introduces a few checks every time vm.cmpSlots are accessed. I felt this was a better tradeoff.

Copy link
Member

Choose a reason for hiding this comment

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

One option is to make VM['cmpSlots'] an optional field.

I think this is a preferable approach to avoid allocating 2 empty objects for all the components that don't accept slots.

Comment on lines 197 to 200
if (!isUndefined(slotset.owner)) {
// Evaluate in the scope of the slot content's owner
setVMBeingRendered(slotset.owner);
}
Copy link
Member

Choose a reason for hiding this comment

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

This branch should always evaluate, in other words there shouldn't be a case where owner is undefined. See the comment below on the SlotSet interface.

Copy link
Contributor Author

@ravijayaramappa ravijayaramappa Oct 14, 2022

Choose a reason for hiding this comment

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

I could remove the isUndefined check and always assume slotset.owner is present. It is indeed present if we consider that root component will never get slotted content.
setVMBeingRendered(slotset.owner!);

Copy link
Member

Choose a reason for hiding this comment

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

I could remove the isUndefined check and always assume slotset.owner is present.

I think this is preferable since it better models the actual runtime behavior. It's impossible in active to have slotted content without an owner.

Copy link
Member

@pmdartus pmdartus left a comment

Choose a reason for hiding this comment

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

Added a couple of last nitpicks otherwise this PR looks good.

Comment on lines 197 to 200
if (!isUndefined(slotset.owner)) {
// Evaluate in the scope of the slot content's owner
setVMBeingRendered(slotset.owner);
}
Copy link
Member

Choose a reason for hiding this comment

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

I could remove the isUndefined check and always assume slotset.owner is present.

I think this is preferable since it better models the actual runtime behavior. It's impossible in active to have slotted content without an owner.

let children: VNodes = [];
if (!isUndefined(slotset.owner)) {
// Evaluate in the scope of the slot content's owner
setVMBeingRendered(slotset.owner);
Copy link
Member

Choose a reason for hiding this comment

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

Side note, setVMBeingRendered is used in 2 different places with this PR: for template evaluation and for fragment evaluation. I was thinking about a way to abstract this since LWC template is just a supercharged fragment.

I haven't found an elegant way to do this for now. It's certainly something worth keeping in the back of our head for the next time we touch this part of the code.

@@ -0,0 +1,40 @@
import { createElement } from 'lwc';
Copy link
Member

Choose a reason for hiding this comment

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

For the sanity test, I would also recommend adding a simple test for SSR.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added

Copy link
Member

@jmsjtu jmsjtu left a comment

Choose a reason for hiding this comment

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

I ran the coverage report and added small nitpicks. Otherwise, looks good!

Copy link
Member

@jmsjtu jmsjtu left a comment

Choose a reason for hiding this comment

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

LGTM!

@ravijayaramappa ravijayaramappa merged commit 2ada663 into master Oct 14, 2022
@ravijayaramappa ravijayaramappa deleted the ravi/master/scoped-slots branch October 14, 2022 22:28
ravijayaramappa added a commit that referenced this pull request Oct 18, 2022
* feat(template-compiler): implement handling for scoped slot directives in template (#3077)

* feat: add handling of lwc:data-bind directive
* feat: add handling for lwc:slot-data directive
* feat: add capability in engine to accept a factory function as slot content

* chore: release v2.27.0 (#3118)
@nolanlawson nolanlawson mentioned this pull request Mar 1, 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.

6 participants