-
Notifications
You must be signed in to change notification settings - Fork 393
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
Conversation
3691114
to
e8f640b
Compare
❌ An unexpected error occurred while attempting to start the |
❌ An unexpected error occurred while attempting to start the |
@@ -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.
Sorry, something went wrong.
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
@@ -473,33 +475,38 @@ export const ParserDiagnostics = { | |||
level: DiagnosticLevel.Error, | |||
url: '', | |||
}, | |||
|
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
// Evaluate in the scope of the slot content's owner | ||
setVMBeingRendered(slotset.owner); | ||
} | ||
children = vnode.factory(data.slotData); |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
There was a problem hiding this comment.
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.
This comment was marked as resolved.
Sorry, something went wrong.
There was a problem hiding this 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.
if (!isUndefined(slotset.owner)) { | ||
// Evaluate in the scope of the slot content's owner | ||
setVMBeingRendered(slotset.owner); | ||
} |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
} finally { | ||
setVMBeingRendered(vmBeingRenderedInception); | ||
} | ||
return acc.concat(children); |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
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.
This comment was marked as resolved.
Sorry, something went wrong.
@@ -35,6 +43,13 @@ export interface BaseVNode { | |||
owner: VM; | |||
} | |||
|
|||
export interface VScopedSlotContent extends BaseVNode { | |||
// TODO [#9999]: should the factory return a VFragment instead? |
There was a problem hiding this comment.
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.
Sorry, something went wrong.
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
parent: ParentNode, | ||
parse5Elm: parse5.Element | ||
): ScopedSlotContent | undefined { | ||
const slotDataAttr = parsedAttr.pick('lwc:slot-data'); |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
@@ -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.
This comment was marked as resolved.
Sorry, something went wrong.
@@ -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.
Sorry, something went wrong.
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
@@ -252,7 +265,8 @@ function parseElementDirectives( | |||
ctx: ParserCtx, | |||
parsedAttr: ParsedAttribute, | |||
parent: ParentNode, | |||
parse5ElmLocation: parse5.ElementLocation | |||
parse5ElmLocation: parse5.ElementLocation, |
There was a problem hiding this comment.
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);
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Filed #3108
There was a problem hiding this 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); |
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
-
Basically,
this.seenScopedSlots
does not need to be if-block aware. The detection logic is handled byctx.hasSeenSlot
. The look up onctx.seenScopedSlots
is to just augment that information to produce an appropriate error/warning message. -
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.
There was a problem hiding this comment.
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.
This comment was marked as resolved.
Sorry, something went wrong.
@@ -356,6 +358,10 @@ export default class CodeGen { | |||
); | |||
} | |||
|
|||
getScopedSlotFactory(callback: t.FunctionExpression, slotName: t.SimpleLiteral) { |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
2f44a97
to
3576c5e
Compare
There was a problem hiding this 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.
| SlotBindDirective | ||
| SlotDataDirective |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
packages/@lwc/errors/src/compiler/error-info/template-transform.ts
Outdated
Show resolved
Hide resolved
...er/src/__tests__/fixtures/scoped-slots/invalid/slot-data/with-for-each-sibling/metadata.json
Outdated
Show resolved
Hide resolved
@@ -0,0 +1,15 @@ | |||
<template> | |||
<x-child> <!-- no `lwc:slot-data` on the component tag --> |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
...ompiler/src/__tests__/fixtures/scoped-slots/invalid/config-unset/child-slot-bind/actual.html
Show resolved
Hide resolved
<template lwc:if={showStandard}> | ||
<slot name="slotname1"></slot> | ||
</template> | ||
<template lwc:elseif={showVariant}> | ||
<slot name="slotname1" lwc:slot-bind={slot1VariantData}></slot> | ||
</template> |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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:
- child and parent use same type of slot: happy path, works fine.
- 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)
.../__tests__/fixtures/scoped-slots/invalid/slot-bind/invalid-on-non-slot-content/metadata.json
Outdated
Show resolved
Hide resolved
@@ -252,7 +265,8 @@ function parseElementDirectives( | |||
ctx: ParserCtx, | |||
parsedAttr: ParsedAttribute, | |||
parent: ParentNode, | |||
parse5ElmLocation: parse5.ElementLocation | |||
parse5ElmLocation: parse5.ElementLocation, |
There was a problem hiding this comment.
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.
} = 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.
Sorry, something went wrong.
[key: string]: VNodes; | ||
// Slot assignments by name | ||
slotAssignments: { [key: string]: VNodes }; | ||
owner?: VM; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
if (!isUndefined(slotset.owner)) { | ||
// Evaluate in the scope of the slot content's owner | ||
setVMBeingRendered(slotset.owner); | ||
} |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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!);
There was a problem hiding this comment.
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.
There was a problem hiding this 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.
if (!isUndefined(slotset.owner)) { | ||
// Evaluate in the scope of the slot content's owner | ||
setVMBeingRendered(slotset.owner); | ||
} |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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'; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added
There was a problem hiding this 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!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM!
* 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)
Details
This PR introduces handling for the two scoped slot directives:
lwc:slot-bind
andlwc:slot-data
.ScopedSlotContent
to represent the template content provided by the parent component.VScopedSlotContent
off
.Follow up PRs:
Does this pull request introduce a breaking change?
Does this pull request introduce an observable change?
GUS work item
W-11469044