From 00344cbc29ecdee5a5aa2ffc6ac11ab068b98691 Mon Sep 17 00:00:00 2001 From: Dale Bustad Date: Wed, 26 Mar 2025 12:43:30 -0700 Subject: [PATCH 1/3] feat: add mechanism to capture hydration telemetry --- .../src/framework/hydration-utils.ts | 24 ++--- .../engine-core/src/framework/hydration.ts | 91 +++++++------------ .../src/framework/runtime-instrumentation.ts | 10 +- 3 files changed, 57 insertions(+), 68 deletions(-) diff --git a/packages/@lwc/engine-core/src/framework/hydration-utils.ts b/packages/@lwc/engine-core/src/framework/hydration-utils.ts index 3447c68b91..2005484724 100644 --- a/packages/@lwc/engine-core/src/framework/hydration-utils.ts +++ b/packages/@lwc/engine-core/src/framework/hydration-utils.ts @@ -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 = []; @@ -26,11 +26,18 @@ interface HydrationError { export type Classes = Omit, '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}"`}`; } @@ -38,7 +45,6 @@ export function prettyPrintAttribute(attribute: string, value: any): string { 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}`; } @@ -48,7 +54,6 @@ 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 }); } @@ -56,7 +61,6 @@ export function queueHydrationError(type: string, serverRendered?: any, clientEx 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:`, @@ -72,7 +76,7 @@ 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; @@ -80,7 +84,7 @@ export function isTypeElement(node?: Node): node is Element { 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; @@ -88,7 +92,7 @@ export function isTypeText(node?: Node): node is Text { 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; @@ -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); } diff --git a/packages/@lwc/engine-core/src/framework/hydration.ts b/packages/@lwc/engine-core/src/framework/hydration.ts index 6c1a65c7d9..46b415e910 100644 --- a/packages/@lwc/engine-core/src/framework/hydration.ts +++ b/packages/@lwc/engine-core/src/framework/hydration.ts @@ -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); } @@ -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); } @@ -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.' ); @@ -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; @@ -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); @@ -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, @@ -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]; @@ -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); } } @@ -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; } @@ -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; } } @@ -684,7 +667,7 @@ function validateClassAttr( const classesAreCompatible = checkClassesCompatibility(vnodeClasses, elmClasses); - if (process.env.NODE_ENV !== 'production' && !classesAreCompatible) { + if (!classesAreCompatible) { queueHydrationError( 'attribute', prettyPrintClasses(elmClasses), @@ -736,7 +719,7 @@ function validateStyleAttr( vnodeStyle = ArrayJoin.call(expectedStyle, ' '); } - if (process.env.NODE_ENV !== 'production' && !nodesAreCompatible) { + if (!nodesAreCompatible) { queueHydrationError( 'attribute', prettyPrintAttribute('style', elmStyle), @@ -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; } @@ -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; } } diff --git a/packages/@lwc/engine-core/src/framework/runtime-instrumentation.ts b/packages/@lwc/engine-core/src/framework/runtime-instrumentation.ts index 3928460d67..55f765cfd6 100644 --- a/packages/@lwc/engine-core/src/framework/runtime-instrumentation.ts +++ b/packages/@lwc/engine-core/src/framework/runtime-instrumentation.ts @@ -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 @@ -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; From e395d55673fc382864dcff7edfbb27763d630f3c Mon Sep 17 00:00:00 2001 From: Dale Bustad Date: Wed, 26 Mar 2025 12:50:19 -0700 Subject: [PATCH 2/3] chore: increase bundle size restriction to accommodate inclusion of telemetry mechanism --- scripts/bundlesize/bundlesize.config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/bundlesize/bundlesize.config.json b/scripts/bundlesize/bundlesize.config.json index e01e9f01e2..7538bb9005 100644 --- a/scripts/bundlesize/bundlesize.config.json +++ b/scripts/bundlesize/bundlesize.config.json @@ -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", From 2aeb56aef30927e28c579f6b5f309d43e6c9cf75 Mon Sep 17 00:00:00 2001 From: Dale Bustad Date: Wed, 26 Mar 2025 15:28:03 -0700 Subject: [PATCH 3/3] chore: fix stupid bug, you dummy --- packages/@lwc/engine-core/src/framework/hydration-utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@lwc/engine-core/src/framework/hydration-utils.ts b/packages/@lwc/engine-core/src/framework/hydration-utils.ts index 2005484724..7887972428 100644 --- a/packages/@lwc/engine-core/src/framework/hydration-utils.ts +++ b/packages/@lwc/engine-core/src/framework/hydration-utils.ts @@ -32,7 +32,7 @@ export type Classes = Omit, 'add'>; * 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; +const hydrationLogger = process.env.NODE_ENV === 'production' ? reportHydrationError : console.warn; /* Prints attributes as null or "value"