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: refactored hydration logging and related tests #5086

Merged
merged 14 commits into from
Jan 9, 2025

Conversation

jhefferman-sfdc
Copy link
Contributor

@jhefferman-sfdc jhefferman-sfdc commented Dec 30, 2024

Details

TD-0232213 requests more explicit hydration errors.
Implement the changes proposed in this spike

Does this pull request introduce a breaking change?

  • ๐Ÿ˜ฎโ€๐Ÿ’จ No, it does not introduce a breaking change.
  • ๐Ÿ”ฌ Yes, it does include an observable change.

GUS work item:

W-17431135

@jhefferman-sfdc jhefferman-sfdc requested a review from a team as a code owner December 30, 2024 18:51
@jhefferman-sfdc jhefferman-sfdc changed the title feat: refactored hydration logging, related test changes feat: refactored hydration logging and related tests Dec 30, 2024
* @param first
* @param second
*/
export function isSanitizedHtmlContentEqual(first: any, second: any) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not used anywhere else

ChildNode = 'child node',
}

class HydrationError {
Copy link
Contributor Author

@jhefferman-sfdc jhefferman-sfdc Dec 31, 2024

Choose a reason for hiding this comment

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

More structured logging to keep errors consistent. The errors are stored and not logged until the source element is fully hydrated.

@@ -514,23 +615,6 @@ function patchElementPropsAndAttrsAndRefs(vnode: VBaseElement, renderer: Rendere
applyRefs(vnode, vnode.owner);
}

function hasCorrectNodeType<T extends Node>(
Copy link
Contributor Author

@jhefferman-sfdc jhefferman-sfdc Dec 31, 2024

Choose a reason for hiding this comment

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

replaced with dedicated guards (isTypeElement, etc) for type inference (avoids having to specify the type in addition to the type parameter)

const { getProperty, getAttribute } = renderer;
if (getProperty(client, 'nodeType') === EnvNodeTypes.TEXT) {
Copy link
Contributor Author

@jhefferman-sfdc jhefferman-sfdc Dec 31, 2024

Choose a reason for hiding this comment

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

IIUC none of this (782-800) would ever execute? areCompatibleStaticNodes is never called unless serverNode (ssr) is of type Element And the check for client can be moved to parent for symmetry? See here

Copy link
Collaborator

Choose a reason for hiding this comment

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

We have code coverage that runs on every commit and can be downloaded from GitHub Actions. I checked, and yes the code is uncovered:

Screenshot 2025-01-02 at 11 14 50 AM

However, this could just be because the hydration checking for static nodes is wrong right now: #4867

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Got you. The redundant logic only appeared to concern Element hydration... my assumption was this method was used for multiple node types in the past?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Missed this. Can we change the client type from Node to Element then?

}
}
}
};
}

function stringifyArg(arg) {
Copy link
Contributor Author

@jhefferman-sfdc jhefferman-sfdc Dec 31, 2024

Choose a reason for hiding this comment

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

Better way to do this? I couldn't decide between just printing the nodeName Or the whole Node. I don't think printing the node makes the tests much better and just adds more to the output? There is also a lot more delta when DISABLE_STATIC_CONTENT_OPTIMIZATION is enabled

@@ -137,12 +234,29 @@ function hydrateNode(node: Node, vnode: VNode, renderer: RendererAPI): Node | nu
hydratedNode = hydrateCustomElement(node, vnode, vnode.data.renderer ?? renderer);
break;
}

flushHydrationErrors(hydratedNode);
Copy link
Contributor Author

@jhefferman-sfdc jhefferman-sfdc Dec 31, 2024

Choose a reason for hiding this comment

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

Errors are queued as they occur and then logged with the source element once it has been hydrated and mounted to the DOM. Means the element in the console matches what is on the page and the highlighting works properly when you hover over the elements in the console.

Copy link
Collaborator

Choose a reason for hiding this comment

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

This would be helpful as a comment. Otherwise it's unclear why we are queueing hydration errors. ๐Ÿ™‚

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sounds good, I put the explanation comment on the function definition but it probably makes sense to have them where they are used, too.

if (isSanitizedHtmlContentEqual(getProperty(elm, 'innerHTML'), props.innerHTML)) {
const unwrappedServerInnerHTML = unwrapIfNecessary(getProperty(elm, 'innerHTML'));
const unwrappedClientInnerHTML = unwrapIfNecessary(props.innerHTML);
if (unwrappedServerInnerHTML === unwrappedClientInnerHTML) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Comparison is done here so the innerHTML can be logged if there is a hydration error

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. This is a big PR, so it may benefit from being broken up. E.g. I would save removing dead code for a future PR.

this.serverRendered = serverRendered;
this.clientExpected = clientExpected;
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

This class adds extra JS at runtime and is pretty unlike most of the other constructs in the LWC engine, which usually deals with plain objects and uses types/interfaces instead. I'd suggest an interface here.

`\n- expected on client:`,
error.clientExpected || source
)
);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Use a regular for loop to avoid forEach/ArrayForEach.call controversy here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks. What is the controversy?

Copy link
Collaborator

Choose a reason for hiding this comment

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

See above about consistency in Array/Object calls. It's controversial because we are not consistent about it.

function logWarn(...args: any) {
/* eslint-disable-next-line no-console */
console.warn('[LWC warn:', ...args);
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

These helpers would be good to put in a hydration-utils.ts or similar. Also logWarn is confusingly named the same as another function in shared/logger.ts.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good idea, thanks

@@ -137,12 +234,29 @@ function hydrateNode(node: Node, vnode: VNode, renderer: RendererAPI): Node | nu
hydratedNode = hydrateCustomElement(node, vnode, vnode.data.renderer ?? renderer);
break;
}

flushHydrationErrors(hydratedNode);
Copy link
Collaborator

Choose a reason for hiding this comment

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

This would be helpful as a comment. Otherwise it's unclear why we are queueing hydration errors. ๐Ÿ™‚

let nextNode: Node | null = node;
const serverNodes =
process.env.NODE_ENV !== 'production'
? Array.from(parentNode?.children).map((c) => c.cloneNode(true))
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
? Array.from(parentNode?.children).map((c) => c.cloneNode(true))
? ArrayFrom(getChildNodes(parentNode), (child) => cloneNode(child, true))

getChildNodes and cloneNode should come from the owner's renderer.

parentNode should never be nullish here, and I believe childNodes makes more sense since we are analyzing at the node level, not the element level, and ShadowRoot has childNodes but not children.

Copy link
Contributor Author

@jhefferman-sfdc jhefferman-sfdc Jan 3, 2025

Choose a reason for hiding this comment

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

Sounds good, thank you, although the parameter for childNodes is an Element so perhaps I'm misunderstanding something here. I have some questions around this and the renderer pattern in general but I can ask you later.

Copy link
Collaborator

Choose a reason for hiding this comment

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

The types in renderer are probably just be wrong here. This function could definitely accept a Node and work the same:

function getChildNodes(element: Element): NodeList {
return element.childNodes;
}

The renderer pattern dates back to the split between engine-server and engine-dom plus some Locker integration points. Happy to discuss offline.

*/
function queueHydrationError(type: string, serverRendered?: any, clientExpected?: any) {
if (process.env.NODE_ENV !== 'production') {
hydrationErrors.push(new HydrationError(type, serverRendered, clientExpected));
Copy link
Collaborator

Choose a reason for hiding this comment

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

Should be ArrayPush.call

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks. Why?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Historically we use language.ts from @lwc/shared for Array and Object prototype methods. Originally this had to do with Locker โ€“ now we do it for consistency and (likely) small performance improvements. Note we are not fully consistent about this across the codebase, but I prefer being as consistent as possible.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Note we only even attempt to consistently do this in client (browser) code. So you won't see this pattern in test code or server-side code at all.

hasMismatch = true;
// We can't know exactly which node(s) caused the delta, but we can provide context (parent) and the mismatched sets
if (process.env.NODE_ENV !== 'production') {
const clientNodes = children?.map((c) => c?.elm);
Copy link
Collaborator

Choose a reason for hiding this comment

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

ArrayMap.call.

'attribute',
`${attrName}=${serverRendered}`,
`${attrName}=${clientExpected}`
);
Copy link
Collaborator

Choose a reason for hiding this comment

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

I tested it out, and it seems Terser is not smart enough to remove the isNull checks and string construction here in development mode. (Nor would that be correct anyway, I guess โ€“ e.g. the object could throw an error on its toString() being called.) I'd suggest wrapping this entire call in a if (process.env.NODE_ENV !== 'production') check.

One pattern we have used in these kinds of situations is to have queueHydrationError do an assertNotProd() check rather than check process.env.NODE_ENV itself. This ensures that the caller only calls it in non-prod mode.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Nice, thank you I was trying to avoid executing logging logic in prod and missed this.

vnode.owner
);
}
queueHydrationError('attribute', `style="${elmStyle}"`, `style="${vnodeStyle}"`);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Same here about string construction.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks, I wanted to avoid the !== production conditionals everywhere but def makes more sense in the consumer to avoid parameter logic. Changed to that + the assertNotProd in the utilities.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Also: we should use prettyPrintAttribute here for code deduplication, and to handle the undefined case for vnodeStyle. I guess prettyPrintAttribute will have to handle isUndefined as well, but not a big deal.

const { getProperty, getAttribute } = renderer;
if (getProperty(client, 'nodeType') === EnvNodeTypes.TEXT) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

We have code coverage that runs on every commit and can be downloaded from GitHub Actions. I checked, and yes the code is uncovered:

Screenshot 2025-01-02 at 11 14 50 AM

However, this could just be because the hydration checking for static nodes is wrong right now: #4867

}
const args = calls[i];
const argsString = args.map((arg) => stringifyArg(arg)).join(' ');
expect(argsString).toMatch(matcher);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Looks like I missed that toMatch on a strig does the same thing as toContain. Good find!

return arg.map((v) => stringifyArg(v));
} else if (arg?.nodeName) {
// Browsers render nodes differently (class order, etc). Node.nodeName is universal and sufficient for testing.
return arg.nodeName;
Copy link
Collaborator

@nolanlawson nolanlawson Jan 3, 2025

Choose a reason for hiding this comment

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

I think I would use

`<${tagName.toLowerCase()}>`

...if tagName is available. To the average web developer, <li> is much more legible than LI. For non-elements, the nodeName is fine (#comment, #text, etc.).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Perfect, thank you. This stringify only impacts the format of the matchers in our karma tests but I still think it is worth changing. The framework consumers will see elements in the console and not strings - when they hover over them it will higlight the matching element on the page.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I missed this but finally grokked it after watching your demo video. Even for the tests though, I think it's a nice touch. But yeah not super important, I get that now. ๐Ÿ˜…

'Mismatch hydrating element <div>: attribute "data-foo" has different values, expected "undefined" but found null',
'Mismatch hydrating element <div>: attribute "data-foo" has different values, expected "null" but found null',
'Hydration attribute mismatch on: DIV - rendered on server: data-foo=null - expected on client: data-foo="undefined"',
'Hydration attribute mismatch on: DIV - rendered on server: data-foo=null - expected on client: data-foo="null"',
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is much more legible than before, but again I think <div> is better than DIV here.

`\n- rendered on server:`,
error.serverRendered,
`\n- expected on client:`,
error.clientExpected || source
Copy link
Collaborator

Choose a reason for hiding this comment

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

The fact that the newlines and string length cause everything to line up is a really nice touch.

@jhefferman-sfdc
Copy link
Contributor Author

First pass. This is a big PR, so it may benefit from being broken up. E.g. I would save removing dead code for a future PR.

Thank you very much for the review, for the dead code I removed it as it would have required a refactor, increases bundle size and isn't executed? In terms of size I figured a good chunk of it was the repetitive test coverage changes but you are right that re-introducing the dead code is probably the easiest candidate for size reduction as everything else is part of a single feature really.

If you feel strongly I will refactor the dead code to use the new type guards and re-introduce it, as you want.

queueHydrationError(
'attribute',
`${attrName}="${serverAttributeValue}"`,
`${attrName}="${clientAttributeValue}"`
Copy link
Collaborator

Choose a reason for hiding this comment

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

This won't pretty-print properly if getAttribute returns null. In that case, it should print without the double-quotes.

Note null vs string is the only case we have to care about here โ€“ getAttribute won't return anything else.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks, replaced with a utility

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.

Overall this LGTM, I just have a few nitpicks. The new warning messages are looking way better than the old ones.

@nolanlawson
Copy link
Collaborator

If you feel strongly I will refactor the dead code to use the new type guards and re-introduce it, as you want.

I looked into it again, and you're right, this code is truly dead. I missed what you had said here. The hasCorrectNodeType check already ensures that we aren't comparing an Element against a non-Element. Feel free to remove it.

@jhefferman-sfdc
Copy link
Contributor Author

jhefferman-sfdc commented Jan 3, 2025

Overall this LGTM, I just have a few nitpicks. The new warning messages are looking way better than the old ones.

Thank you, I think some of the big wins will be:

  • logging the actual elements so they are higlighted on hover
  • no more generic errors, e.g. 'Hydration mismatch: incorrect node type received' is now 'Hydration node mismatch on: #text - rendered on server: #comment - expected on client: #text',

I hope LWR like it! If you have time, feel free to take a look at this spike doc which has a comparison with other frameworks, demos, etc.

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.

Looking very good to me! A few remaining nitpicks, but otherwise :shipit:

*/
export function flushHydrationErrors(source?: Node | null) {
assertNotProd(); // this method should never leak to prod
for (let i = 0; i < hydrationErrors.length; i++) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

No biggie, but for future reference for...of is fine. I think the old-school for loop still wins on micro-benchmarks, but it's not important for dev-only code.

*/
flushHydrationErrors(vm.renderRoot);
if (hasMismatch) {
logHydrationError('Hydration completed with errors.');
Copy link
Collaborator

Choose a reason for hiding this comment

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

Probably time to rename this from "error" to "warning" but no big deal.


const serverNodes =
process.env.NODE_ENV !== 'production'
? Array.from(getChildNodes(parentNode)).map((c) => c.cloneNode(true))
Copy link
Collaborator

Choose a reason for hiding this comment

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

ArrayFrom, ArrayMap, renderer.cloneNode, etc. Also the ArrayMap is not necessary because the second argument to ArrayFrom can be a map function.

vnode.owner
);
}
queueHydrationError('attribute', `style="${elmStyle}"`, `style="${vnodeStyle}"`);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Also: we should use prettyPrintAttribute here for code deduplication, and to handle the undefined case for vnodeStyle. I guess prettyPrintAttribute will have to handle isUndefined as well, but not a big deal.

'Hydration text content mismatch on: <p> - rendered on server: hello! - expected on client: bye!',
],
});
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is a bit odd โ€“ we could potentially rewrite the hydration warnings to align these two cases. It's not a big deal though.

Copy link
Contributor Author

@jhefferman-sfdc jhefferman-sfdc Jan 3, 2025

Choose a reason for hiding this comment

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

What did you have in mind? I didn't take a deep look but my understanding was the generic Hydration mismatch: text values do not match, will recover from the difference message masked the fact that the nodes were different when the flag was enabled. Could open an issue for further enhancement?

Copy link
Collaborator

@nolanlawson nolanlawson Jan 3, 2025

Choose a reason for hiding this comment

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

It's not a big deal. The issue here is that, for the static content optimization, we detect the mismatch at the element level, whereas without the optimization, we detect it at the text level.

Likely we should just figure out what's wrong with static content mismatches entirely: #4867. We have also seen this kind of issue before: #2962

@jhefferman-sfdc jhefferman-sfdc merged commit 47c46eb into master Jan 9, 2025
10 checks passed
@jhefferman-sfdc jhefferman-sfdc deleted the jhefferman/hydration-logging branch January 9, 2025 22:36
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.

2 participants