Skip to content

Commit

Permalink
fix(hydration): only ignore mutated host attributes (#4385)
Browse files Browse the repository at this point in the history
  • Loading branch information
nolanlawson authored Jul 17, 2024
1 parent ae5e7be commit 4d13710
Show file tree
Hide file tree
Showing 25 changed files with 188 additions and 15 deletions.
9 changes: 6 additions & 3 deletions packages/@lwc/engine-core/src/framework/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
APIFeature,
isAPIFeatureEnabled,
isFalse,
StringSplit,
} from '@lwc/shared';

import { logError, logWarn } from '../shared/logger';
Expand Down Expand Up @@ -165,9 +166,11 @@ function getValidationPredicate(
optOutStaticProp: string[] | true | undefined
): AttrValidationPredicate {
// `data-lwc-host-mutated` is a special attribute added by the SSR engine itself,
// which does the same thing as an explicit `static validationOptOut = true`.
if (renderer.getAttribute(elm, 'data-lwc-host-mutated') === '') {
return (_attrName: string) => false;
// which does the same thing as an explicit `static validationOptOut = ['attr1', 'attr2']`.
const hostMutatedValue = renderer.getAttribute(elm, 'data-lwc-host-mutated');
if (isString(hostMutatedValue)) {
const mutatedAttrValues = new Set(StringSplit.call(hostMutatedValue, / /));
return (attrName: string) => !mutatedAttrValues.has(attrName);
}

if (isUndefined(optOutStaticProp)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<x-cmp aria-label="haha" class="yolo woot" data-foo="bar" data-lwc-host-mutated="aria-label class data-foo">
<template shadowrootmode="open">
</template>
</x-cmp>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const tagName = 'x-cmp';
export { default } from 'x/cmp';
export * from 'x/cmp';
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<template>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { LightningElement } from 'lwc';

export default class extends LightningElement {
connectedCallback() {
// Modify this component's class and attributes at runtime
// We expect a data-lwc-host-mutated attr to be added with the mutated attribute names in unique sorted order
this.setAttribute('data-foo', 'bar')
this.classList.add('yolo')
this.classList.add('woot')
this.setAttribute('aria-label', 'haha')
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<x-cmp aria-activedescendant="foo" aria-busy="true" data-lwc-host-mutated>
<x-cmp aria-activedescendant="foo" aria-busy="true" data-lwc-host-mutated="aria-activedescendant aria-busy">
<template shadowrootmode="open">
</template>
</x-cmp>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<x-cmp ARIA-LABEL="haha" DATA-FOO="bar" data-lwc-host-mutated="aria-label data-bar data-foo">
<template shadowrootmode="open">
</template>
</x-cmp>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const tagName = 'x-cmp';
export { default } from 'x/cmp';
export * from 'x/cmp';
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<template>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { LightningElement } from 'lwc';

export default class extends LightningElement {
connectedCallback() {
// Modify this component's attributes at runtime, using uppercase
// We expect a data-lwc-host-mutated attr to be added with the mutated attribute names in unique sorted order,
// all lowercase
this.setAttribute('DATA-FOO', 'bar')
this.setAttribute('ARIA-LABEL', 'haha')
this.removeAttribute('dAtA-BaR')
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<x-getter-class-list class="a c d-e" data-lwc-host-mutated>
<x-getter-class-list class="a c d-e" data-lwc-host-mutated="class">
<template shadowrootmode="open">
</template>
</x-getter-class-list>
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<x-method-remove-attribute data-a data-c data-lwc-host-mutated>
<x-method-remove-attribute data-a data-c data-lwc-host-mutated="data-a data-b data-c data-unknown">
<template shadowrootmode="open">
</template>
</x-method-remove-attribute>
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<x-method-set-attribute data-boolean="true" data-empty-string data-lwc-host-mutated data-null="null" data-number="1" data-override="override" data-string="test">
<x-method-set-attribute data-boolean="true" data-empty-string data-lwc-host-mutated="data-boolean data-empty-string data-null data-number data-override data-string" data-null="null" data-number="1" data-override="override" data-string="test">
<template shadowrootmode="open">
</template>
</x-method-set-attribute>
8 changes: 4 additions & 4 deletions packages/@lwc/engine-server/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ function getAttribute(element: E, name: string, namespace: string | null = null)
}

function setAttribute(element: E, name: string, value: string, namespace: string | null = null) {
reportMutation(element);
reportMutation(element, name);
const attribute = element[HostAttributesKey].find(
(attr) => attr.name === name && attr[HostNamespaceKey] === namespace
);
Expand All @@ -279,7 +279,7 @@ function setAttribute(element: E, name: string, value: string, namespace: string
}

function removeAttribute(element: E, name: string, namespace?: string | null) {
reportMutation(element);
reportMutation(element, name);
element[HostAttributesKey] = element[HostAttributesKey].filter(
(attr) => attr.name !== name && attr[HostNamespaceKey] !== namespace
);
Expand All @@ -305,15 +305,15 @@ function getClassList(element: E) {

return {
add(...names: string[]): void {
reportMutation(element);
reportMutation(element, 'class');
const classAttribute = getClassAttribute();

const tokenList = classNameToTokenList(classAttribute.value);
names.forEach((name) => tokenList.add(name));
classAttribute.value = tokenListToClassName(tokenList);
},
remove(...names: string[]): void {
reportMutation(element);
reportMutation(element, 'class');
const classAttribute = getClassAttribute();

const tokenList = classNameToTokenList(classAttribute.value);
Expand Down
17 changes: 13 additions & 4 deletions packages/@lwc/engine-server/src/utils/mutation-tracking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,25 @@ const elementsToTrackForMutations: WeakSet<HostElement> = new WeakSet();

const MUTATION_TRACKING_ATTRIBUTE = 'data-lwc-host-mutated';

export function reportMutation(element: HostElement) {
export function reportMutation(element: HostElement, attributeName: string) {
if (elementsToTrackForMutations.has(element)) {
const hasMutationAttribute = element[HostAttributesKey].find(
const existingMutationAttribute = element[HostAttributesKey].find(
(attr) => attr.name === MUTATION_TRACKING_ATTRIBUTE && attr[HostNamespaceKey] === null
);
if (!hasMutationAttribute) {
const attrNameValues = new Set(
existingMutationAttribute ? existingMutationAttribute.value.split(' ') : []
);
attrNameValues.add(attributeName.toLowerCase());

const newMutationAttributeValue = [...attrNameValues].sort().join(' ');

if (existingMutationAttribute) {
existingMutationAttribute.value = newMutationAttributeValue;
} else {
element[HostAttributesKey].push({
name: MUTATION_TRACKING_ATTRIBUTE,
[HostNamespaceKey]: null,
value: '',
value: newMutationAttributeValue,
});
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
export default {
props: {
ssr: true,
},
clientProps: {
ssr: false,
},
snapshot(target) {
const child = target.shadowRoot.querySelector('x-child');
const div = child.shadowRoot.querySelector('div');

return {
child,
div,
};
},
test(target, snapshots, consoleCalls) {
const snapshotAfterHydration = this.snapshot(target);
expect(snapshotAfterHydration.child).not.toBe(snapshots.child);
expect(snapshotAfterHydration.div).not.toBe(snapshots.div);

const { child } = snapshotAfterHydration;
expect(child.getAttribute('data-foo')).toBe('bar');
expect(child.getAttribute('data-mutatis')).toBe('mutandis');
expect(child.getAttribute('class')).toBe('is-client');

TestUtils.expectConsoleCallsDev(consoleCalls, {
warn: [],
error: [
'Mismatch hydrating element <x-child>: attribute "class" has different values, expected "is-client" but found "is-server"',
'Hydration completed with errors.',
],
});
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<div>{yolo}</div>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { LightningElement } from 'lwc';

export default class Child extends LightningElement {
yolo = 'woot';

connectedCallback() {
this.setAttribute('data-mutatis', 'mutandis');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<x-child data-foo="bar" class={mismatchingClass}></x-child>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { LightningElement, api } from 'lwc';

export default class Main extends LightningElement {
@api ssr;

get mismatchingClass() {
return this.ssr ? 'is-server' : 'is-client';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
export default {
props: {
ssr: true,
},
clientProps: {
ssr: false,
},
snapshot(target) {
const child = target.shadowRoot.querySelector('x-child');
const div = child.shadowRoot.querySelector('div');

return {
child,
div,
};
},
test(target, snapshots, consoleCalls) {
const snapshotAfterHydration = this.snapshot(target);
expect(snapshotAfterHydration.child).not.toBe(snapshots.child);
expect(snapshotAfterHydration.div).not.toBe(snapshots.div);

const { child } = snapshotAfterHydration;
expect(child.getAttribute('class')).toBe('static mutatis');
expect(child.getAttribute('data-mismatched-attr')).toBe('is-client');

TestUtils.expectConsoleCallsDev(consoleCalls, {
warn: [],
error: [
'Mismatch hydrating element <x-child>: attribute "data-mismatched-attr" has different values, expected "is-client" but found "is-server"',
'Hydration completed with errors.',
],
});
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<div>{yolo}</div>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { LightningElement } from 'lwc';

export default class Child extends LightningElement {
yolo = 'woot';

connectedCallback() {
this.classList.add('mutatis');
this.classList.add('mutandis');
this.classList.remove('mutandis');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<x-child class="static" data-mismatched-attr={mismatchedAttr}></x-child>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { LightningElement, api } from 'lwc';

export default class Main extends LightningElement {
@api ssr;

get mismatchedAttr() {
return this.ssr ? 'is-server' : 'is-client';
}
}

0 comments on commit 4d13710

Please sign in to comment.