-
Notifications
You must be signed in to change notification settings - Fork 398
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
Conversation
* @param first | ||
* @param second | ||
*/ | ||
export function isSanitizedHtmlContentEqual(first: any, second: any) { |
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.
Not used anywhere else
ChildNode = 'child node', | ||
} | ||
|
||
class HydrationError { |
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.
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>( |
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.
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) { |
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.
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
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.
We have code coverage that runs on every commit and can be downloaded from GitHub Actions. I checked, and yes the code is uncovered:
However, this could just be because the hydration checking for static nodes is wrong right now: #4867
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.
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?
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.
Missed this. Can we change the client
type from Node
to Element
then?
} | ||
} | ||
} | ||
}; | ||
} | ||
|
||
function stringifyArg(arg) { |
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.
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); |
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.
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.
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 would be helpful as a comment. Otherwise it's unclear why we are queueing hydration errors. ๐
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.
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) { |
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.
Comparison is done here so the innerHTML can be logged if there is a hydration error
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. 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; | ||
} | ||
} |
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 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 type
s/interface
s instead. I'd suggest an interface
here.
`\n- expected on client:`, | ||
error.clientExpected || source | ||
) | ||
); |
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.
Use a regular for
loop to avoid forEach
/ArrayForEach.call
controversy here.
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.
Thanks. What is the controversy?
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.
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); | ||
} |
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.
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
.
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.
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); |
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 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)) |
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.
? 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
.
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.
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.
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 types in renderer
are probably just be wrong here. This function could definitely accept a Node and work the same:
lwc/packages/@lwc/engine-dom/src/renderer/index.ts
Lines 175 to 177 in 1a82b99
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)); |
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.
Should be ArrayPush.call
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.
Thanks. Why?
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.
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.
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 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); |
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.
ArrayMap.call
.
'attribute', | ||
`${attrName}=${serverRendered}`, | ||
`${attrName}=${clientExpected}` | ||
); |
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 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.
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.
Nice, thank you I was trying to avoid executing logging logic in prod and missed this.
vnode.owner | ||
); | ||
} | ||
queueHydrationError('attribute', `style="${elmStyle}"`, `style="${vnodeStyle}"`); |
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.
Same here about string construction.
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.
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.
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.
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) { |
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.
We have code coverage that runs on every commit and can be downloaded from GitHub Actions. I checked, and yes the code is uncovered:
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); |
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.
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; |
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 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.).
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.
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.
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 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"', |
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 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 |
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 fact that the newlines and string length cause everything to line up is a really nice touch.
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}"` |
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 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.
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.
Thanks, replaced with a utility
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.
Overall this LGTM, I just have a few nitpicks. The new warning messages are looking way better than the old ones.
I looked into it again, and you're right, this code is truly dead. I missed what you had said here. The |
Thank you, I think some of the big wins will be:
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. |
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.
Looking very good to me! A few remaining nitpicks, but otherwise
*/ | ||
export function flushHydrationErrors(source?: Node | null) { | ||
assertNotProd(); // this method should never leak to prod | ||
for (let i = 0; i < hydrationErrors.length; i++) { |
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.
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.'); |
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.
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)) |
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.
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}"`); |
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.
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!', | ||
], | ||
}); | ||
} |
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 is a bit odd โ we could potentially rewrite the hydration warnings to align these two cases. It's not a big deal though.
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.
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?
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.
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
Details
TD-0232213 requests more explicit hydration errors.
Implement the changes proposed in this spike
Does this pull request introduce a breaking change?
GUS work item:
W-17431135