Skip to content

Commit

Permalink
fix(hydration): ignore host scope token in class validation (#4865)
Browse files Browse the repository at this point in the history
Co-authored-by: Nicolas Robert Dehault <[email protected]>
  • Loading branch information
nolanlawson and nrobertdehault authored Nov 15, 2024
1 parent b47e18c commit d2be62a
Show file tree
Hide file tree
Showing 80 changed files with 575 additions and 167 deletions.
129 changes: 59 additions & 70 deletions packages/@lwc/engine-core/src/framework/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import {
keys,
isNull,
isArray,
ArrayFilter,
isTrue,
isString,
StringToLowerCase,
Expand All @@ -21,6 +20,9 @@ import {
isFalse,
StringSplit,
parseStyleText,
ArrayFrom,
ArraySort,
ArrayFilter,
} from '@lwc/shared';

import { logWarn } from '../shared/logger';
Expand Down Expand Up @@ -48,7 +50,6 @@ import {
VCustomElement,
VStatic,
VFragment,
isVCustomElement,
VElementData,
VStaticPartData,
VStaticPartText,
Expand All @@ -58,7 +59,7 @@ import {
import { patchProps } from './modules/props';
import { applyEventListeners } from './modules/events';
import { hydrateStaticParts, traverseAndSetElements } from './modules/static-parts';
import { getScopeTokenClass, getStylesheetTokenHost } from './stylesheet';
import { getScopeTokenClass } from './stylesheet';
import { renderComponent } from './component';
import { applyRefs } from './modules/refs';
import { isSanitizedHtmlContentEqual } from './sanitized-html-content';
Expand Down Expand Up @@ -627,92 +628,80 @@ function validateClassAttr(
const { owner } = vnode;
// classMap is never available on VStaticPartData so it can default to undefined
// casting to prevent TS error.
let { className, classMap } = data as VElementData;
const { getProperty, getClassList, getAttribute } = renderer;
const { className, classMap } = data as VElementData;
const { getProperty } = renderer;

// ---------- Step 1: get the classes from the element and the vnode

// Use a Set because we don't care to validate mismatches for 1) different ordering in SSR vs CSR, or 2)
// duplicated class names. These don't have an effect on rendered styles.
const elmClasses = new Set(ArrayFrom(elm.classList));
let vnodeClasses: Set<string>;

if (!isUndefined(className)) {
// ignore empty spaces entirely, filter them out using `filter(..., Boolean)`
vnodeClasses = new Set(ArrayFilter.call(StringSplit.call(className, /\s+/), Boolean));
} else if (!isUndefined(classMap)) {
vnodeClasses = new Set(keys(classMap));
} else {
vnodeClasses = new Set();
}

// ---------- Step 2: handle the scope tokens

// we don't care about legacy for hydration. it's a new use case
const scopedToken = getScopeTokenClass(owner, /* legacy */ false);
const stylesheetTokenHost = isVCustomElement(vnode) ? getStylesheetTokenHost(vnode) : null;
const scopeToken = getScopeTokenClass(owner, /* legacy */ false);

// Classnames for scoped CSS are added directly to the DOM during rendering,
// or to the VDOM on the server in the case of SSR. As such, these classnames
// are never present in VDOM nodes in the browser.
//
// Consequently, hydration mismatches will occur if scoped CSS token classnames
// are rendered during SSR. This needs to be accounted for when validating.
if (!isNull(scopedToken) || !isNull(stylesheetTokenHost)) {
if (!isUndefined(className)) {
// The order of the className should be scopedToken className stylesheetTokenHost
const classTokens = [scopedToken, className, stylesheetTokenHost];
const classNames = ArrayFilter.call(classTokens, (token) => !isNull(token));
className = ArrayJoin.call(classNames, ' ');
} else if (!isUndefined(classMap)) {
classMap = {
...classMap,
...(!isNull(scopedToken) ? { [scopedToken]: true } : {}),
...(!isNull(stylesheetTokenHost) ? { [stylesheetTokenHost]: true } : {}),
};
} else {
// The order of the className should be scopedToken stylesheetTokenHost
const classTokens = [scopedToken, stylesheetTokenHost];
const classNames = ArrayFilter.call(classTokens, (token) => !isNull(token));
if (classNames.length) {
className = ArrayJoin.call(classNames, ' ');
}
}
if (!isNull(scopeToken)) {
vnodeClasses.add(scopeToken);
}

let nodesAreCompatible = true;
let readableVnodeClassname;
// This tells us which `*-host` scope token was rendered to the element's class.
// For now we just ignore any mismatches involving this class.
// TODO [#4866]: correctly validate the host scope token class
const elmHostScopeToken = renderer.getAttribute(elm, 'data-lwc-host-scope-token');
if (!isNull(elmHostScopeToken)) {
elmClasses.delete(elmHostScopeToken);
vnodeClasses.delete(elmHostScopeToken);
}

const elmClassName = getAttribute(elm, 'class');
// ---------- Step 3: check for compatibility

if (
!isUndefined(className) &&
String(className) !== elmClassName &&
// No mismatch if SSR `class` attribute is missing and CSR `class` is the empty string
!(className === '' && isNull(elmClassName))
) {
// className is used when class is bound to an expr.
let nodesAreCompatible = true;

if (vnodeClasses.size !== elmClasses.size) {
nodesAreCompatible = false;
// stringify for pretty-printing
readableVnodeClassname = JSON.stringify(className);
} else if (!isUndefined(classMap)) {
// classMap is used when class is set to static value.
const classList = getClassList(elm);
let computedClassName = '';

// all classes from the vnode should be in the element.classList
for (const name in classMap) {
computedClassName += ' ' + name;
if (!classList.contains(name)) {
} else {
for (const vnodeClass of vnodeClasses) {
if (!elmClasses.has(vnodeClass)) {
nodesAreCompatible = false;
}
}

// stringify for pretty-printing
readableVnodeClassname = JSON.stringify(computedClassName.trim());

if (classList.length > keys(classMap).length) {
nodesAreCompatible = false;
for (const elmClass of elmClasses) {
if (!vnodeClasses.has(elmClass)) {
nodesAreCompatible = false;
}
}
} else if (isUndefined(className) && !isNull(elmClassName)) {
// SSR contains a className but client-side VDOM does not
nodesAreCompatible = false;
readableVnodeClassname = '""';
}

if (!nodesAreCompatible) {
if (process.env.NODE_ENV !== 'production') {
logWarn(
`Mismatch hydrating element <${getProperty(
elm,
'tagName'
).toLowerCase()}>: attribute "class" has different values, expected ${readableVnodeClassname} but found ${JSON.stringify(
elmClassName
)}`,
vnode.owner
);
}
if (process.env.NODE_ENV !== 'production' && !nodesAreCompatible) {
const prettyPrint = (set: Set<string>) =>
JSON.stringify(ArrayJoin.call(ArraySort.call(ArrayFrom(set)), ' '));
logWarn(
`Mismatch hydrating element <${getProperty(
elm,
'tagName'
).toLowerCase()}>: attribute "class" has different values, expected ${prettyPrint(
vnodeClasses
)} but found ${prettyPrint(elmClasses)}`,
vnode.owner
);
}

return nodesAreCompatible;
Expand Down
8 changes: 7 additions & 1 deletion packages/@lwc/engine-core/src/framework/stylesheet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,13 @@ export function updateStylesheetToken(vm: VM, template: Template, legacy: boolea
// Set the new styling token on the host element
if (!isUndefined(newToken)) {
if (hasScopedStyles) {
getClassList(elm).add(makeHostToken(newToken));
const hostScopeTokenClass = makeHostToken(newToken);
getClassList(elm).add(hostScopeTokenClass);
if (!process.env.IS_BROWSER) {
// This is only used in SSR to communicate to hydration that
// this class should be treated specially for purposes of hydration mismatches.
setAttribute(elm, 'data-lwc-host-scope-token', hostScopeTokenClass);
}
newHasTokenInClass = true;
}
if (isSyntheticShadow) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<x-parent>
<template shadowrootmode="open">
<x-child class="lwc-5h3d35cke7v-host">
<x-child class="lwc-5h3d35cke7v-host" data-lwc-host-scope-token="lwc-5h3d35cke7v-host">
<template shadowrootmode="open">
<style class="lwc-5h3d35cke7v" type="text/css">
:host {background: blue;}
Expand All @@ -13,7 +13,7 @@
</x-grandchild>
</template>
</x-child>
<x-child class="lwc-5h3d35cke7v-host">
<x-child class="lwc-5h3d35cke7v-host" data-lwc-host-scope-token="lwc-5h3d35cke7v-host">
<template shadowrootmode="open">
<style class="lwc-5h3d35cke7v" type="text/css">
:host {background: blue;}
Expand All @@ -26,7 +26,7 @@
</x-grandchild>
</template>
</x-child>
<x-child class="lwc-5h3d35cke7v-host">
<x-child class="lwc-5h3d35cke7v-host" data-lwc-host-scope-token="lwc-5h3d35cke7v-host">
<template shadowrootmode="open">
<style class="lwc-5h3d35cke7v" type="text/css">
:host {background: blue;}
Expand All @@ -39,7 +39,7 @@
</x-grandchild>
</template>
</x-child>
<x-child class="lwc-5h3d35cke7v-host">
<x-child class="lwc-5h3d35cke7v-host" data-lwc-host-scope-token="lwc-5h3d35cke7v-host">
<template shadowrootmode="open">
<style class="lwc-5h3d35cke7v" type="text/css">
:host {background: blue;}
Expand All @@ -52,7 +52,7 @@
</x-grandchild>
</template>
</x-child>
<x-child class="lwc-5h3d35cke7v-host">
<x-child class="lwc-5h3d35cke7v-host" data-lwc-host-scope-token="lwc-5h3d35cke7v-host">
<template shadowrootmode="open">
<style class="lwc-5h3d35cke7v" type="text/css">
:host {background: blue;}
Expand All @@ -65,7 +65,7 @@
</x-grandchild>
</template>
</x-child>
<x-child class="lwc-5h3d35cke7v-host">
<x-child class="lwc-5h3d35cke7v-host" data-lwc-host-scope-token="lwc-5h3d35cke7v-host">
<template shadowrootmode="open">
<style class="lwc-5h3d35cke7v" type="text/css">
:host {background: blue;}
Expand All @@ -78,7 +78,7 @@
</x-grandchild>
</template>
</x-child>
<x-child class="lwc-5h3d35cke7v-host">
<x-child class="lwc-5h3d35cke7v-host" data-lwc-host-scope-token="lwc-5h3d35cke7v-host">
<template shadowrootmode="open">
<style class="lwc-5h3d35cke7v" type="text/css">
:host {background: blue;}
Expand All @@ -91,7 +91,7 @@
</x-grandchild>
</template>
</x-child>
<x-child class="lwc-5h3d35cke7v-host">
<x-child class="lwc-5h3d35cke7v-host" data-lwc-host-scope-token="lwc-5h3d35cke7v-host">
<template shadowrootmode="open">
<style class="lwc-5h3d35cke7v" type="text/css">
:host {background: blue;}
Expand All @@ -104,7 +104,7 @@
</x-grandchild>
</template>
</x-child>
<x-child class="lwc-5h3d35cke7v-host">
<x-child class="lwc-5h3d35cke7v-host" data-lwc-host-scope-token="lwc-5h3d35cke7v-host">
<template shadowrootmode="open">
<style class="lwc-5h3d35cke7v" type="text/css">
:host {background: blue;}
Expand All @@ -117,7 +117,7 @@
</x-grandchild>
</template>
</x-child>
<x-child class="lwc-5h3d35cke7v-host">
<x-child class="lwc-5h3d35cke7v-host" data-lwc-host-scope-token="lwc-5h3d35cke7v-host">
<template shadowrootmode="open">
<style class="lwc-5h3d35cke7v" type="text/css">
:host {background: blue;}
Expand All @@ -130,7 +130,7 @@
</x-grandchild>
</template>
</x-child>
<x-child class="lwc-5h3d35cke7v-host">
<x-child class="lwc-5h3d35cke7v-host" data-lwc-host-scope-token="lwc-5h3d35cke7v-host">
<template shadowrootmode="open">
<style class="lwc-5h3d35cke7v" type="text/css">
:host {background: blue;}
Expand All @@ -143,7 +143,7 @@
</x-grandchild>
</template>
</x-child>
<x-child class="lwc-5h3d35cke7v-host">
<x-child class="lwc-5h3d35cke7v-host" data-lwc-host-scope-token="lwc-5h3d35cke7v-host">
<template shadowrootmode="open">
<style class="lwc-5h3d35cke7v" type="text/css">
:host {background: blue;}
Expand All @@ -156,7 +156,7 @@
</x-grandchild>
</template>
</x-child>
<x-child class="lwc-5h3d35cke7v-host">
<x-child class="lwc-5h3d35cke7v-host" data-lwc-host-scope-token="lwc-5h3d35cke7v-host">
<template shadowrootmode="open">
<style class="lwc-5h3d35cke7v" type="text/css">
:host {background: blue;}
Expand All @@ -169,7 +169,7 @@
</x-grandchild>
</template>
</x-child>
<x-child class="lwc-5h3d35cke7v-host">
<x-child class="lwc-5h3d35cke7v-host" data-lwc-host-scope-token="lwc-5h3d35cke7v-host">
<template shadowrootmode="open">
<style class="lwc-5h3d35cke7v" type="text/css">
:host {background: blue;}
Expand All @@ -182,7 +182,7 @@
</x-grandchild>
</template>
</x-child>
<x-child class="lwc-5h3d35cke7v-host">
<x-child class="lwc-5h3d35cke7v-host" data-lwc-host-scope-token="lwc-5h3d35cke7v-host">
<template shadowrootmode="open">
<style class="lwc-5h3d35cke7v" type="text/css">
:host {background: blue;}
Expand All @@ -195,7 +195,7 @@
</x-grandchild>
</template>
</x-child>
<x-child class="lwc-5h3d35cke7v-host">
<x-child class="lwc-5h3d35cke7v-host" data-lwc-host-scope-token="lwc-5h3d35cke7v-host">
<template shadowrootmode="open">
<style class="lwc-5h3d35cke7v" type="text/css">
:host {background: blue;}
Expand All @@ -208,7 +208,7 @@
</x-grandchild>
</template>
</x-child>
<x-child class="lwc-5h3d35cke7v-host">
<x-child class="lwc-5h3d35cke7v-host" data-lwc-host-scope-token="lwc-5h3d35cke7v-host">
<template shadowrootmode="open">
<style class="lwc-5h3d35cke7v" type="text/css">
:host {background: blue;}
Expand Down
Loading

0 comments on commit d2be62a

Please sign in to comment.