Skip to content

feat: add mechanism to capture hydration telemetry #5308

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

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 13 additions & 11 deletions packages/@lwc/engine-core/src/framework/hydration-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/
import { ArrayPush, ArrayJoin, ArraySort, ArrayFrom, isNull, isUndefined } from '@lwc/shared';

import { assertNotProd } from './utils';
import { reportHydrationError } from './runtime-instrumentation';

// Errors that occured during the hydration process
let hydrationErrors: Array<HydrationError> = [];
Expand All @@ -26,19 +26,25 @@ interface HydrationError {

export type Classes = Omit<Set<string>, 'add'>;

/**
* When the framework is running in dev mode, hydration errors will be reported to the console. When
* running in prod mode, hydration errors will be reported through a global telemetry mechanism, if
* one is provided. If not provided, error reporting is a no-op.
*/
/* eslint-disable-next-line no-console */
const hydrationLogger = process.env.NODE_ENV === 'production' ? reportHydrationError : console.warn;

/*
Prints attributes as null or "value"
*/
export function prettyPrintAttribute(attribute: string, value: any): string {
assertNotProd(); // this method should never leak to prod
return `${attribute}=${isNull(value) || isUndefined(value) ? value : `"${value}"`}`;
}

/*
Sorts and stringifies classes
*/
export function prettyPrintClasses(classes: Classes) {
assertNotProd(); // this method should never leak to prod
const value = JSON.stringify(ArrayJoin.call(ArraySort.call(ArrayFrom(classes)), ' '));
return `class=${value}`;
}
Expand All @@ -48,15 +54,13 @@ export function prettyPrintClasses(classes: Classes) {
queue them so they can be logged later against the mounted node.
*/
export function queueHydrationError(type: string, serverRendered?: any, clientExpected?: any) {
assertNotProd(); // this method should never leak to prod
ArrayPush.call(hydrationErrors, { type, serverRendered, clientExpected });
}

/*
Flushes (logs) any queued errors after the source node has been mounted.
*/
export function flushHydrationErrors(source?: Node | null) {
assertNotProd(); // this method should never leak to prod
for (const hydrationError of hydrationErrors) {
logHydrationWarning(
`Hydration ${hydrationError.type} mismatch on:`,
Expand All @@ -72,23 +76,23 @@ export function flushHydrationErrors(source?: Node | null) {

export function isTypeElement(node?: Node): node is Element {
const isCorrectType = node?.nodeType === EnvNodeTypes.ELEMENT;
if (process.env.NODE_ENV !== 'production' && !isCorrectType) {
if (!isCorrectType) {
queueHydrationError('node', node);
}
return isCorrectType;
}

export function isTypeText(node?: Node): node is Text {
const isCorrectType = node?.nodeType === EnvNodeTypes.TEXT;
if (process.env.NODE_ENV !== 'production' && !isCorrectType) {
if (!isCorrectType) {
queueHydrationError('node', node);
}
return isCorrectType;
}

export function isTypeComment(node?: Node): node is Comment {
const isCorrectType = node?.nodeType === EnvNodeTypes.COMMENT;
if (process.env.NODE_ENV !== 'production' && !isCorrectType) {
if (!isCorrectType) {
queueHydrationError('node', node);
}
return isCorrectType;
Expand All @@ -99,7 +103,5 @@ export function isTypeComment(node?: Node): node is Comment {
legacy bloat which would have meant more pathing.
*/
export function logHydrationWarning(...args: any) {
assertNotProd(); // this method should never leak to prod
/* eslint-disable-next-line no-console */
console.warn('[LWC warn:', ...args);
hydrationLogger('[LWC warn:', ...args);
}
91 changes: 35 additions & 56 deletions packages/@lwc/engine-core/src/framework/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,15 +97,13 @@ export function hydrateRoot(vm: VM) {
runConnectedCallback(vm);
hydrateVM(vm);

if (process.env.NODE_ENV !== 'production') {
/*
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.
*/
flushHydrationErrors(vm.renderRoot);
if (hasMismatch) {
logHydrationWarning('Hydration completed with errors.');
}
/*
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.
*/
flushHydrationErrors(vm.renderRoot);
if (hasMismatch) {
logHydrationWarning('Hydration completed with errors.');
}
logGlobalOperationEndWithVM(OperationId.GlobalSsrHydrate, vm);
}
Expand Down Expand Up @@ -159,13 +157,11 @@ function hydrateNode(node: Node, vnode: VNode, renderer: RendererAPI): Node | nu
break;
}

if (process.env.NODE_ENV !== 'production') {
/*
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.
*/
flushHydrationErrors(hydratedNode);
}
/*
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.
*/
flushHydrationErrors(hydratedNode);

return renderer.nextSibling(hydratedNode);
}
Expand Down Expand Up @@ -214,12 +210,7 @@ function getValidationPredicate(
const isValidArray = isArray(optOutStaticProp) && arrayEvery(optOutStaticProp, isString);
const conditionalOptOut = isValidArray ? new Set(optOutStaticProp) : undefined;

if (
process.env.NODE_ENV !== 'production' &&
!isUndefined(optOutStaticProp) &&
!isTrue(optOutStaticProp) &&
!isValidArray
) {
if (!isUndefined(optOutStaticProp) && !isTrue(optOutStaticProp) && !isValidArray) {
logHydrationWarning(
'`validationOptOut` must be `true` or an array of attributes that should not be validated.'
);
Expand Down Expand Up @@ -255,9 +246,7 @@ function updateTextContent(
vnode: VText | VStaticPartText,
renderer: RendererAPI
): Node | null {
if (process.env.NODE_ENV !== 'production') {
validateTextNodeEquality(node, vnode, renderer);
}
validateTextNodeEquality(node, vnode, renderer);
const { setText } = renderer;
setText(node, vnode.text ?? null);
vnode.elm = node;
Expand All @@ -269,6 +258,9 @@ function hydrateComment(node: Node, vnode: VComment, renderer: RendererAPI): Nod
if (!isTypeComment(node)) {
return handleMismatch(node, vnode, renderer);
}
// When running in production, we skip validation of comment nodes. This is partly because
// the content of those nodes is immaterial to the success of hydration, and partly because
// doing the check via DOM APIs is an unnecessary cost.
if (process.env.NODE_ENV !== 'production') {
const { getProperty } = renderer;
const nodeValue = getProperty(node, NODE_VALUE_PROP);
Expand Down Expand Up @@ -356,7 +348,7 @@ function hydrateElement(elm: Node, vnode: VElement, renderer: RendererAPI): Node
...vnode.data,
props: cloneAndOmitKey(props, 'innerHTML'),
};
} else if (process.env.NODE_ENV !== 'production') {
} else {
queueHydrationError(
'innerHTML',
unwrappedServerInnerHTML,
Expand Down Expand Up @@ -452,10 +444,6 @@ function hydrateChildren(
const { renderer } = owner;
const { getChildNodes, cloneNode } = renderer;

const serverNodes =
process.env.NODE_ENV !== 'production'
? Array.from(getChildNodes(parentNode), (node) => cloneNode(node, true))
: null;
for (let i = 0; i < children.length; i++) {
const childVnode = children[i];

Expand Down Expand Up @@ -501,12 +489,11 @@ function hydrateChildren(
}

if (mismatchedChildren) {
const serverNodes = Array.from(getChildNodes(parentNode), (node) => cloneNode(node, true));
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 = ArrayMap.call(children, (c) => c?.elm);
queueHydrationError('child node', serverNodes, clientNodes);
}
const clientNodes = ArrayMap.call(children, (c) => c?.elm);
queueHydrationError('child node', serverNodes, clientNodes);
}
}

Expand Down Expand Up @@ -535,9 +522,7 @@ function isMatchingElement(
) {
const { getProperty } = renderer;
if (vnode.sel.toLowerCase() !== getProperty(elm, 'tagName').toLowerCase()) {
if (process.env.NODE_ENV !== 'production') {
queueHydrationError('node', elm);
}
queueHydrationError('node', elm);
return false;
}

Expand Down Expand Up @@ -592,13 +577,11 @@ function validateAttrs(
const { getAttribute } = renderer;
const elmAttrValue = getAttribute(elm, attrName);
if (!attributeValuesAreEqual(attrValue, elmAttrValue)) {
if (process.env.NODE_ENV !== 'production') {
queueHydrationError(
'attribute',
prettyPrintAttribute(attrName, elmAttrValue),
prettyPrintAttribute(attrName, attrValue)
);
}
queueHydrationError(
'attribute',
prettyPrintAttribute(attrName, elmAttrValue),
prettyPrintAttribute(attrName, attrValue)
);
nodesAreCompatible = false;
}
}
Expand Down Expand Up @@ -684,7 +667,7 @@ function validateClassAttr(

const classesAreCompatible = checkClassesCompatibility(vnodeClasses, elmClasses);

if (process.env.NODE_ENV !== 'production' && !classesAreCompatible) {
if (!classesAreCompatible) {
queueHydrationError(
'attribute',
prettyPrintClasses(elmClasses),
Expand Down Expand Up @@ -736,7 +719,7 @@ function validateStyleAttr(
vnodeStyle = ArrayJoin.call(expectedStyle, ' ');
}

if (process.env.NODE_ENV !== 'production' && !nodesAreCompatible) {
if (!nodesAreCompatible) {
queueHydrationError(
'attribute',
prettyPrintAttribute('style', elmStyle),
Expand All @@ -758,9 +741,7 @@ function areStaticElementsCompatible(
let isCompatibleElements = true;

if (getProperty(clientElement, 'tagName') !== getProperty(serverElement, 'tagName')) {
if (process.env.NODE_ENV !== 'production') {
queueHydrationError('node', serverElement);
}
queueHydrationError('node', serverElement);
return false;
}

Expand All @@ -777,13 +758,11 @@ function areStaticElementsCompatible(
// Note if there are no parts then it is a fully static fragment.
// partId === 0 will always refer to the root element, this is guaranteed by the compiler.
if (parts?.[0].partId !== 0) {
if (process.env.NODE_ENV !== 'production') {
queueHydrationError(
'attribute',
prettyPrintAttribute(attrName, serverAttributeValue),
prettyPrintAttribute(attrName, clientAttributeValue)
);
}
queueHydrationError(
'attribute',
prettyPrintAttribute(attrName, serverAttributeValue),
prettyPrintAttribute(attrName, clientAttributeValue)
);
isCompatibleElements = false;
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2023, salesforce.com, inc.
* Copyright (c) 2025, salesforce.com, inc.
* All rights reserved.
* SPDX-License-Identifier: MIT
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT
Expand All @@ -8,3 +8,11 @@ import { noop } from '@lwc/shared';

export const instrumentDef = (globalThis as any).__lwc_instrument_cmp_def ?? noop;
export const instrumentInstance = (globalThis as any).__lwc_instrument_cmp_instance ?? noop;

/**
* This is a mechanism that allow for reporting of hydration issues to a telemetry backend. The
* `__lwc_report_hydration_error` function must be defined in the global scope prior to import
* of the LWC framework. It must accept any number of args, in the same manner that `console.log`
* does. These args are not pre-stringified but should be stringifiable.
*/
export const reportHydrationError = (globalThis as any).__lwc_report_hydration_error ?? noop;
2 changes: 1 addition & 1 deletion scripts/bundlesize/bundlesize.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"files": [
{
"path": "packages/@lwc/engine-dom/dist/index.js",
"maxSize": "24KB"
"maxSize": "24.5KB"
},
{
"path": "packages/@lwc/synthetic-shadow/dist/index.js",
Expand Down
Loading