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

[labs/ssr] Fix hydration for nested custom elements without attributes #3942

Merged
merged 4 commits into from
Jun 13, 2023
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/eighty-gifts-sparkle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@lit-labs/ssr': patch
---

Fix adding node marker for hydration for nested custom elements without attributes.
augustjk marked this conversation as resolved.
Show resolved Hide resolved
153 changes: 74 additions & 79 deletions packages/labs/ssr/src/lib/render-value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,87 +394,82 @@ const getTemplateOpcodes = (result: TemplateResult) => {
});
}
}
if (node.attrs.length > 0) {
augustjk marked this conversation as resolved.
Show resolved Hide resolved
const attrInfo = [] as Array<
[boolean, boolean, (typeof node.attrs)[0]]
>;
for (const attr of node.attrs) {
const isAttrBinding = attr.name.endsWith(boundAttributeSuffix);
const isElementBinding = attr.name.startsWith(marker);
if (isAttrBinding || isElementBinding) {
boundAttributesCount += 1;
}
attrInfo.push([isAttrBinding, isElementBinding, attr]);
}
if (boundAttributesCount > 0 || node.isDefinedCustomElement) {
// We (may) need to emit a `<!-- lit-node -->` comment marker to
// indicate the following node needs to be identified during
// hydration when it has bindings or if it is a custom element (and
// thus may need its `defer-hydration` to be removed, depending on
// the `deferHydration` setting). The marker is emitted as a
// previous sibling before the node in question, to avoid issues
// with void elements (which do not have children) and raw text
// elements (whose children are intepreted as text).
flushTo(node.sourceCodeLocation!.startTag!.startOffset);
ops.push({
type: 'possible-node-marker',
boundAttributesCount,
nodeIndex,
});
const attrInfo = node.attrs.map((attr) => {
const isAttrBinding = attr.name.endsWith(boundAttributeSuffix);
const isElementBinding = attr.name.startsWith(marker);
if (isAttrBinding || isElementBinding) {
boundAttributesCount += 1;
}
for (const [isAttrBinding, isElementBinding, attr] of attrInfo) {
if (isAttrBinding || isElementBinding) {
// Note that although we emit a lit-node comment marker for any
// nodes with bindings, we don't account for it in the nodeIndex because
// that will not be injected into the client template
const strings = attr.value.split(marker);
// We store the case-sensitive name from `attrNames` (generated
// while parsing the template strings); note that this assumes
// parse5 attribute ordering matches string ordering
const name = attrNames[attrIndex++];
const attrSourceLocation =
node.sourceCodeLocation!.attrs![attr.name]!;
const attrNameStartOffset = attrSourceLocation.startOffset;
const attrEndOffset = attrSourceLocation.endOffset;
flushTo(attrNameStartOffset);
if (isAttrBinding) {
const [, prefix, caseSensitiveName] = /([.?@])?(.*)/.exec(
name as string
)!;
ops.push({
type: 'attribute-part',
index: nodeIndex,
name: caseSensitiveName,
ctor:
prefix === '.'
? PropertyPart
: prefix === '?'
? BooleanAttributePart
: prefix === '@'
? EventPart
: AttributePart,
strings,
tagName: tagName.toUpperCase(),
useCustomElementInstance: node.isDefinedCustomElement,
});
} else {
ops.push({
type: 'element-part',
index: nodeIndex,
});
}
skipTo(attrEndOffset);
} else if (node.isDefinedCustomElement) {
// For custom elements, all static attributes are stored along
// with the `custom-element-open` opcode so that we can set them
// into the custom element instance, and then serialize them back
// out along with any manually-reflected attributes. As such, we
// skip over static attribute text here.
const attrSourceLocation =
node.sourceCodeLocation!.attrs![attr.name]!;
flushTo(attrSourceLocation.startOffset);
skipTo(attrSourceLocation.endOffset);
return [isAttrBinding, isElementBinding, attr] as const;
});
if (boundAttributesCount > 0 || node.isDefinedCustomElement) {
// We (may) need to emit a `<!-- lit-node -->` comment marker to
// indicate the following node needs to be identified during
// hydration when it has bindings or if it is a custom element (and
// thus may need its `defer-hydration` to be removed, depending on
// the `deferHydration` setting). The marker is emitted as a
// previous sibling before the node in question, to avoid issues
// with void elements (which do not have children) and raw text
// elements (whose children are intepreted as text).
flushTo(node.sourceCodeLocation!.startTag!.startOffset);
ops.push({
type: 'possible-node-marker',
boundAttributesCount,
nodeIndex,
});
}
for (const [isAttrBinding, isElementBinding, attr] of attrInfo) {
if (isAttrBinding || isElementBinding) {
// Note that although we emit a lit-node comment marker for any
// nodes with bindings, we don't account for it in the nodeIndex because
// that will not be injected into the client template
const strings = attr.value.split(marker);
// We store the case-sensitive name from `attrNames` (generated
// while parsing the template strings); note that this assumes
// parse5 attribute ordering matches string ordering
const name = attrNames[attrIndex++];
const attrSourceLocation =
node.sourceCodeLocation!.attrs![attr.name]!;
const attrNameStartOffset = attrSourceLocation.startOffset;
const attrEndOffset = attrSourceLocation.endOffset;
flushTo(attrNameStartOffset);
if (isAttrBinding) {
const [, prefix, caseSensitiveName] = /([.?@])?(.*)/.exec(
name as string
)!;
ops.push({
type: 'attribute-part',
index: nodeIndex,
name: caseSensitiveName,
ctor:
prefix === '.'
? PropertyPart
: prefix === '?'
? BooleanAttributePart
: prefix === '@'
? EventPart
: AttributePart,
strings,
tagName: tagName.toUpperCase(),
useCustomElementInstance: node.isDefinedCustomElement,
});
} else {
ops.push({
type: 'element-part',
index: nodeIndex,
});
}
skipTo(attrEndOffset);
} else if (node.isDefinedCustomElement) {
// For custom elements, all static attributes are stored along
// with the `custom-element-open` opcode so that we can set them
// into the custom element instance, and then serialize them back
// out along with any manually-reflected attributes. As such, we
// skip over static attribute text here.
const attrSourceLocation =
node.sourceCodeLocation!.attrs![attr.name]!;
flushTo(attrSourceLocation.startOffset);
skipTo(attrSourceLocation.endOffset);
}
}

Expand Down
48 changes: 48 additions & 0 deletions packages/labs/ssr/src/test/integration/tests/basic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5261,6 +5261,54 @@ export const tests: {[name: string]: SSRTest} = {
};
},

'LitElement: hydrate nested element without attrs': () => {
// Regression test for https://github.com/lit/lit/issues/3939
//
// Confirms nested custom elements should have their defer-hydration
// attribute removed when parent is hydrated even without any attributes or
// bindings
return {
registerElements() {
class LEParent extends LitElement {
override render() {
return html`<le-child></le-child>`;
}
}
customElements.define('le-parent', LEParent);

class LEChild extends LitElement {
override render() {
return html`le-child`;
}
}
customElements.define('le-child', LEChild);
},
render() {
return html`<le-parent></le-parent>`;
},
expectations: [
{
args: [],
async check(assert: Chai.Assert, dom: HTMLElement) {
const parent = dom.querySelector('le-parent') as LitElement;
await parent.updateComplete;
const child = parent.shadowRoot!.querySelector(
'le-child'
) as LitElement;
assert.isFalse(child.hasAttribute('defer-hydration'));
},
html: {
root: `<le-parent></le-parent>`,
'le-parent': {
root: `<le-child></le-child>`,
},
},
},
],
stableSelectors: ['le-parent'],
};
},

'LitElement: ElementInternals': () => {
return {
// ElementInternals is not implemented in Safari yet
Expand Down