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

perf: apply static parts optimization to dynamic attributes - part 2 #4056

Conversation

jmsjtu
Copy link
Member

@jmsjtu jmsjtu commented Mar 13, 2024

Details

This is part 2 of 2 to apply static parts optimization to dynamic attributes as part of #3624.

This second PR handles the actual changes to our static content optimization to allow attributes with expressions.

The commits in the PR are broken down to more easily review:

  1. e98ebdb contains the code changes to enable the optimization
  2. f09d7d2 contains the template compiler test fixture updates
  3. 1b3b461 contains the @lwc/engine-server test fixture changes
  4. 5ec09d0 contains the karma tests
  5. f1257b0 contains the hydration tests

Does this pull request introduce a breaking change?

  • 😮‍💨 No, it does not introduce a breaking change.

Does this pull request introduce an observable change?

  • 🔬 Yes, it does include an observable change.

There is an observable change in SSR rendering for MATHML elements that use an attribute as an expression.

When the element contains an attribute expression and has no children it will now render with a self-closing tag. This is the correct behavior and it looks like the previous behavior did not capture it.

EX:

<mo form={expression}></mo>

In SSR this will now serialize as:

<mo form="expression value" />

Previously it would render without the closing tag:

<mo form="expression value"></mo>

GUS work item

W-15174789

@jmsjtu jmsjtu requested a review from a team as a code owner March 13, 2024 19:14
Comment on lines -77 to +92
return (attrs.length > 0 ? ' ' : '') + attrs.join(' ') + (hasClassAttr ? '${2}' : '${3}');
return attrs.join('') + (hasClassAttr ? '${2}' : '${3}');
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 modified the spacing here so that it's easier to join at runtime, the main issue before was removing the leading space when running on a browser (serialization not required).

attrs.push(`${name}="${htmlEscape(v, true)}"`);
// Inject a placeholder where the staticPartId will go when an expression occurs.
// This is only needed for SSR to inject the expression value during serialization.
attrs.push(hasExpression ? `\${"${v}"}` : ` ${name}="${htmlEscape(v, true)}"`);
Copy link
Member Author

Choose a reason for hiding this comment

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

When an expression is encountered, a token is inserted in its place, see the template compiler test fixtures for details.

Comment on lines 23 to 24
oldnode: VBaseElement | VStaticPart | null,
node: VBaseElement | VStaticPart,
Copy link
Member Author

Choose a reason for hiding this comment

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

In the browser, attribute patching for static parts is the same as with other elements.

Comment on lines +124 to +134
return (partToken: string) => {
// The partTokens are split into 3 section:
// 1. The first character represents the expression type (attribute, class, style, or text).
// 2. The characters from index 1 to the first occurrence of a ':' is the partId.
// 3. Everything after the first ':' represents the attribute name.
// For example: a0:data-name, a = an attribute, 0 = partId, data-name = attribute name.
const type = partToken.charAt(0);
const delimiterIndex = partToken.indexOf(':');
const partId = partToken.substring(1, delimiterIndex);
const rest = partToken.substring(delimiterIndex + 1);
const part = partIdsToParts.get(partId) ?? EmptyObject;
Copy link
Member Author

@jmsjtu jmsjtu Mar 13, 2024

Choose a reason for hiding this comment

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

The format of the tokens will be {type}{partId}:{attributeName}, for example a0:data-name.

The token serves as an index to determine which static part value to use for serialization.

The reason this is required is that our current SSR implementation is not compatible with how we are applying attributes in the browser.

The main issue is the vnode.fragment used in mountStatic has already been assembled by this routine during the mount process.

In the browser, we rely on cloneNode to create the DOM elements, loop through those elements and apply the attributes to them.

However, in SSR, the cloneNode function just returns the string value in vnode.fragment. This is why we need to insert the expression values at the time the string is being built.

I thought about replicating the cloneNode behavior to reproduce the nodes in SSR but it would require an HTML parser to generate the nodes.

I feel that this is the most straight forward approach, although I think we should refactor this function to have different implementations between @lwc/engine-dom and @lwc/engine-server.

Copy link
Contributor

Choose a reason for hiding this comment

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

Great description! We should capture this in a high-level issue and add a TODO here. I think it would be great to have one place where we document this (very big) difference between engine-server and engine-dom.

It may ultimately get resolved by the dedicated SSR compiler.

Copy link
Member Author

Choose a reason for hiding this comment

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

Add a issue! #4078


if (!isUndefined(parts)) {
// Elements must first be set on the static part to validate against.
traverseAndSetElements(elm, parts, renderer);
Copy link
Member Author

Choose a reason for hiding this comment

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

This step is needed before the validation of the static parts can occur since we compare vnodes against the actual element.

We could alternatively validate while we're traversing down the DOM nodes but I thought this was easier.

for (const attribute of node.attributes) {
const { name, value } = attribute;
if (isExpression(value)) {
this.staticExpressionMap.set(attribute, `a${partId}:${name}`);
Copy link
Member Author

Choose a reason for hiding this comment

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

Plan to stash all the tokens at this point and they will all follow a similar format:

{type}{partId}:{rest}

a -> attribute
c -> class
s -> style
t -> text

Only attributes will have a value following the : since we need to identify which attribute to map it to.

@@ -1,25 +1,25 @@
import { registerTemplate } from "lwc";
import { parseFragment, registerTemplate } from "lwc";
const $fragment1 = parseFragment`<a${"a0:href"}${3}>KIX</a>`;
Copy link
Member Author

Choose a reason for hiding this comment

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

This is an example of how the tokens will appear.


const ColonCharCode = 58;

export function patchAttributes(
oldVnode: VBaseElement | null,
vnode: VBaseElement,
oldnode: VBaseElement | VStaticPart | null,
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 changed the variable names to node and oldNode because VStaticPart technically isn't a vnode as we define it.

Open to suggestions though!

Copy link
Contributor

Choose a reason for hiding this comment

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

oldnode: VBaseElement | VStaticPart | null,

I changed the variable names to node and oldNode

🤨 🤭

Copy link
Contributor

@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 review, will do more reviewing later!

}
}
return true;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

I think an important invariant here is:

  1. It's never the case that parts is undefined on the server but defined on the client (or vice-versa)
  2. It's never the case that parts has one length on the server but another on the client

This might be worth a comment.

Copy link
Member Author

Choose a reason for hiding this comment

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

Added a comment!

// Event listeners only need to be applied once when mounting
applyEventListeners(part, renderer);
// Refs must be updated after every render due to refVNodes getting reset before every render
applyRefs(part, owner);
Copy link
Contributor

Choose a reason for hiding this comment

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

Why don't we handle attributes here? Because, if there is no hydration mismatch, then we assume that SSR gave us the correct attributes?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yup, exactly. If there are no hydration mismatches then there shouldn't be a need to patch attributes (because they will be the same).

I'll add a comment explaining it here.

packages/@lwc/engine-core/src/framework/template.ts Outdated Show resolved Hide resolved
packages/@lwc/engine-core/src/framework/template.ts Outdated Show resolved Hide resolved
element: Element,
preserveComments: boolean,
codeGen: CodeGen
): string {
Copy link
Contributor

Choose a reason for hiding this comment

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

If we're passing in the whole codeGen then there's no need to pass in preserveComments as well. You can get it from the codeGen.


const ColonCharCode = 58;

export function patchAttributes(
oldVnode: VBaseElement | null,
vnode: VBaseElement,
oldnode: VBaseElement | VStaticPart | null,
Copy link
Contributor

Choose a reason for hiding this comment

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

oldnode: VBaseElement | VStaticPart | null,

I changed the variable names to node and oldNode

🤨 🤭

expect(nodes['deep3nested'].getAttribute('data-dynamic')).toEqual('3');
expect(nodes['deep4'].getAttribute('data-dynamic')).toEqual('4');
expect(nodes['deep4nested'].getAttribute('data-dynamic')).toEqual('4');
});
Copy link
Contributor

Choose a reason for hiding this comment

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

It might be interesting to add a test with a variation on this template where everything is in one <div>. That way, they are all separate parts of a single static vnode.

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'll make a note and include with the style and class attributes.

Copy link
Contributor

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

This LGTM. It's a bit disappointing that we have to add so much complexity to handle @lwc/engine-server, but 1) that should hopefully go away thanks to the SSR compiler, and 2) it's not adding much overhead, thanks to tree-shaking and the minimal "a0:data-foo" string system you came up with. (A throwaway string is lower overhead than, for instance, a throwaway array.)

I think we should add some additional fixture tests to the template compiler to ensure that the indexes for this string format (a0, a1, etc.) work correctly with code comments and preserve-comments on vs off. This is something that's bit us in the past: #3933.

I also think this is a good opportunity to do a manual Nucleus before/after comparison before merging, just as a sanity check.

Nice work!

@jmsjtu jmsjtu merged commit b198989 into jtu/static-content-optimization Mar 19, 2024
3 checks passed
@jmsjtu jmsjtu deleted the jtu/static-content-optimization-attributes branch March 19, 2024 18:43
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.

None yet

4 participants