Skip to content

Commit a37466f

Browse files
committed
feat: add mechanism to capture hydration telemetry
1 parent 672be9a commit a37466f

File tree

3 files changed

+57
-68
lines changed

3 files changed

+57
-68
lines changed

packages/@lwc/engine-core/src/framework/hydration-utils.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
*/
77
import { ArrayPush, ArrayJoin, ArraySort, ArrayFrom, isNull, isUndefined } from '@lwc/shared';
88

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

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

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

29+
/**
30+
* When the framework is running in dev mode, hydration errors will be reported to the console. When
31+
* running in prod mode, hydration errors will be reported through a global telemetry mechanism, if
32+
* one is provided. If not provided, error reporting is a no-op.
33+
*/
34+
/* eslint-disable-next-line no-console */
35+
const hydrationLogger = process.env.NODE_ENV !== 'production' ? reportHydrationError : console.warn;
36+
2937
/*
3038
Prints attributes as null or "value"
3139
*/
3240
export function prettyPrintAttribute(attribute: string, value: any): string {
33-
assertNotProd(); // this method should never leak to prod
3441
return `${attribute}=${isNull(value) || isUndefined(value) ? value : `"${value}"`}`;
3542
}
3643

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

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

7377
export function isTypeElement(node?: Node): node is Element {
7478
const isCorrectType = node?.nodeType === EnvNodeTypes.ELEMENT;
75-
if (process.env.NODE_ENV !== 'production' && !isCorrectType) {
79+
if (!isCorrectType) {
7680
queueHydrationError('node', node);
7781
}
7882
return isCorrectType;
7983
}
8084

8185
export function isTypeText(node?: Node): node is Text {
8286
const isCorrectType = node?.nodeType === EnvNodeTypes.TEXT;
83-
if (process.env.NODE_ENV !== 'production' && !isCorrectType) {
87+
if (!isCorrectType) {
8488
queueHydrationError('node', node);
8589
}
8690
return isCorrectType;
8791
}
8892

8993
export function isTypeComment(node?: Node): node is Comment {
9094
const isCorrectType = node?.nodeType === EnvNodeTypes.COMMENT;
91-
if (process.env.NODE_ENV !== 'production' && !isCorrectType) {
95+
if (!isCorrectType) {
9296
queueHydrationError('node', node);
9397
}
9498
return isCorrectType;
@@ -99,7 +103,5 @@ export function isTypeComment(node?: Node): node is Comment {
99103
legacy bloat which would have meant more pathing.
100104
*/
101105
export function logHydrationWarning(...args: any) {
102-
assertNotProd(); // this method should never leak to prod
103-
/* eslint-disable-next-line no-console */
104-
console.warn('[LWC warn:', ...args);
106+
hydrationLogger('[LWC warn:', ...args);
105107
}

packages/@lwc/engine-core/src/framework/hydration.ts

Lines changed: 35 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -97,15 +97,13 @@ export function hydrateRoot(vm: VM) {
9797
runConnectedCallback(vm);
9898
hydrateVM(vm);
9999

100-
if (process.env.NODE_ENV !== 'production') {
101-
/*
102-
Errors are queued as they occur and then logged with the source element once it has been hydrated and mounted to the DOM.
103-
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.
104-
*/
105-
flushHydrationErrors(vm.renderRoot);
106-
if (hasMismatch) {
107-
logHydrationWarning('Hydration completed with errors.');
108-
}
100+
/*
101+
Errors are queued as they occur and then logged with the source element once it has been hydrated and mounted to the DOM.
102+
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.
103+
*/
104+
flushHydrationErrors(vm.renderRoot);
105+
if (hasMismatch) {
106+
logHydrationWarning('Hydration completed with errors.');
109107
}
110108
logGlobalOperationEndWithVM(OperationId.GlobalSsrHydrate, vm);
111109
}
@@ -159,13 +157,11 @@ function hydrateNode(node: Node, vnode: VNode, renderer: RendererAPI): Node | nu
159157
break;
160158
}
161159

162-
if (process.env.NODE_ENV !== 'production') {
163-
/*
164-
Errors are queued as they occur and then logged with the source element once it has been hydrated and mounted to the DOM.
165-
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.
166-
*/
167-
flushHydrationErrors(hydratedNode);
168-
}
160+
/*
161+
Errors are queued as they occur and then logged with the source element once it has been hydrated and mounted to the DOM.
162+
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.
163+
*/
164+
flushHydrationErrors(hydratedNode);
169165

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

217-
if (
218-
process.env.NODE_ENV !== 'production' &&
219-
!isUndefined(optOutStaticProp) &&
220-
!isTrue(optOutStaticProp) &&
221-
!isValidArray
222-
) {
213+
if (!isUndefined(optOutStaticProp) && !isTrue(optOutStaticProp) && !isValidArray) {
223214
logHydrationWarning(
224215
'`validationOptOut` must be `true` or an array of attributes that should not be validated.'
225216
);
@@ -255,9 +246,7 @@ function updateTextContent(
255246
vnode: VText | VStaticPartText,
256247
renderer: RendererAPI
257248
): Node | null {
258-
if (process.env.NODE_ENV !== 'production') {
259-
validateTextNodeEquality(node, vnode, renderer);
260-
}
249+
validateTextNodeEquality(node, vnode, renderer);
261250
const { setText } = renderer;
262251
setText(node, vnode.text ?? null);
263252
vnode.elm = node;
@@ -269,6 +258,9 @@ function hydrateComment(node: Node, vnode: VComment, renderer: RendererAPI): Nod
269258
if (!isTypeComment(node)) {
270259
return handleMismatch(node, vnode, renderer);
271260
}
261+
// When running in production, we skip validation of comment nodes. This is partly because
262+
// the content of those nodes is immaterial to the success of hydration, and partly because
263+
// doing the check via DOM APIs is an unnecessary cost.
272264
if (process.env.NODE_ENV !== 'production') {
273265
const { getProperty } = renderer;
274266
const nodeValue = getProperty(node, NODE_VALUE_PROP);
@@ -356,7 +348,7 @@ function hydrateElement(elm: Node, vnode: VElement, renderer: RendererAPI): Node
356348
...vnode.data,
357349
props: cloneAndOmitKey(props, 'innerHTML'),
358350
};
359-
} else if (process.env.NODE_ENV !== 'production') {
351+
} else {
360352
queueHydrationError(
361353
'innerHTML',
362354
unwrappedServerInnerHTML,
@@ -452,10 +444,6 @@ function hydrateChildren(
452444
const { renderer } = owner;
453445
const { getChildNodes, cloneNode } = renderer;
454446

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

@@ -501,12 +489,11 @@ function hydrateChildren(
501489
}
502490

503491
if (mismatchedChildren) {
492+
const serverNodes = Array.from(getChildNodes(parentNode), (node) => cloneNode(node, true));
504493
hasMismatch = true;
505494
// We can't know exactly which node(s) caused the delta, but we can provide context (parent) and the mismatched sets
506-
if (process.env.NODE_ENV !== 'production') {
507-
const clientNodes = ArrayMap.call(children, (c) => c?.elm);
508-
queueHydrationError('child node', serverNodes, clientNodes);
509-
}
495+
const clientNodes = ArrayMap.call(children, (c) => c?.elm);
496+
queueHydrationError('child node', serverNodes, clientNodes);
510497
}
511498
}
512499

@@ -535,9 +522,7 @@ function isMatchingElement(
535522
) {
536523
const { getProperty } = renderer;
537524
if (vnode.sel.toLowerCase() !== getProperty(elm, 'tagName').toLowerCase()) {
538-
if (process.env.NODE_ENV !== 'production') {
539-
queueHydrationError('node', elm);
540-
}
525+
queueHydrationError('node', elm);
541526
return false;
542527
}
543528

@@ -592,13 +577,11 @@ function validateAttrs(
592577
const { getAttribute } = renderer;
593578
const elmAttrValue = getAttribute(elm, attrName);
594579
if (!attributeValuesAreEqual(attrValue, elmAttrValue)) {
595-
if (process.env.NODE_ENV !== 'production') {
596-
queueHydrationError(
597-
'attribute',
598-
prettyPrintAttribute(attrName, elmAttrValue),
599-
prettyPrintAttribute(attrName, attrValue)
600-
);
601-
}
580+
queueHydrationError(
581+
'attribute',
582+
prettyPrintAttribute(attrName, elmAttrValue),
583+
prettyPrintAttribute(attrName, attrValue)
584+
);
602585
nodesAreCompatible = false;
603586
}
604587
}
@@ -684,7 +667,7 @@ function validateClassAttr(
684667

685668
const classesAreCompatible = checkClassesCompatibility(vnodeClasses, elmClasses);
686669

687-
if (process.env.NODE_ENV !== 'production' && !classesAreCompatible) {
670+
if (!classesAreCompatible) {
688671
queueHydrationError(
689672
'attribute',
690673
prettyPrintClasses(elmClasses),
@@ -736,7 +719,7 @@ function validateStyleAttr(
736719
vnodeStyle = ArrayJoin.call(expectedStyle, ' ');
737720
}
738721

739-
if (process.env.NODE_ENV !== 'production' && !nodesAreCompatible) {
722+
if (!nodesAreCompatible) {
740723
queueHydrationError(
741724
'attribute',
742725
prettyPrintAttribute('style', elmStyle),
@@ -758,9 +741,7 @@ function areStaticElementsCompatible(
758741
let isCompatibleElements = true;
759742

760743
if (getProperty(clientElement, 'tagName') !== getProperty(serverElement, 'tagName')) {
761-
if (process.env.NODE_ENV !== 'production') {
762-
queueHydrationError('node', serverElement);
763-
}
744+
queueHydrationError('node', serverElement);
764745
return false;
765746
}
766747

@@ -777,13 +758,11 @@ function areStaticElementsCompatible(
777758
// Note if there are no parts then it is a fully static fragment.
778759
// partId === 0 will always refer to the root element, this is guaranteed by the compiler.
779760
if (parts?.[0].partId !== 0) {
780-
if (process.env.NODE_ENV !== 'production') {
781-
queueHydrationError(
782-
'attribute',
783-
prettyPrintAttribute(attrName, serverAttributeValue),
784-
prettyPrintAttribute(attrName, clientAttributeValue)
785-
);
786-
}
761+
queueHydrationError(
762+
'attribute',
763+
prettyPrintAttribute(attrName, serverAttributeValue),
764+
prettyPrintAttribute(attrName, clientAttributeValue)
765+
);
787766
isCompatibleElements = false;
788767
}
789768
}
Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2023, salesforce.com, inc.
2+
* Copyright (c) 2025, salesforce.com, inc.
33
* All rights reserved.
44
* SPDX-License-Identifier: MIT
55
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT
@@ -8,3 +8,11 @@ import { noop } from '@lwc/shared';
88

99
export const instrumentDef = (globalThis as any).__lwc_instrument_cmp_def ?? noop;
1010
export const instrumentInstance = (globalThis as any).__lwc_instrument_cmp_instance ?? noop;
11+
12+
/**
13+
* This is a mechanism that allow for reporting of hydration issues to a telemetry backend. The
14+
* `__lwc_report_hydration_error` function must be defined in the global scope prior to import
15+
* of the LWC framework. It must accept any number of args, in the same manner that `console.log`
16+
* does. These args are not pre-stringified but should be stringifiable.
17+
*/
18+
export const reportHydrationError = (globalThis as any).__lwc_report_hydration_error ?? noop;

0 commit comments

Comments
 (0)