diff --git a/packages/@lwc/engine-core/src/framework/hydration.ts b/packages/@lwc/engine-core/src/framework/hydration.ts index 8eb93b7fd4..736b1ec488 100644 --- a/packages/@lwc/engine-core/src/framework/hydration.ts +++ b/packages/@lwc/engine-core/src/framework/hydration.ts @@ -20,6 +20,7 @@ import { APIFeature, isAPIFeatureEnabled, isFalse, + StringSplit, } from '@lwc/shared'; import { logError, logWarn } from '../shared/logger'; @@ -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)) { diff --git a/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-and-class-modify/expected.html b/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-and-class-modify/expected.html new file mode 100644 index 0000000000..407c60ae1f --- /dev/null +++ b/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-and-class-modify/expected.html @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-and-class-modify/index.js b/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-and-class-modify/index.js new file mode 100644 index 0000000000..55d4302e11 --- /dev/null +++ b/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-and-class-modify/index.js @@ -0,0 +1,3 @@ +export const tagName = 'x-cmp'; +export { default } from 'x/cmp'; +export * from 'x/cmp'; diff --git a/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-and-class-modify/modules/x/cmp/cmp.html b/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-and-class-modify/modules/x/cmp/cmp.html new file mode 100644 index 0000000000..6beff5199f --- /dev/null +++ b/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-and-class-modify/modules/x/cmp/cmp.html @@ -0,0 +1,2 @@ + diff --git a/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-and-class-modify/modules/x/cmp/cmp.js b/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-and-class-modify/modules/x/cmp/cmp.js new file mode 100644 index 0000000000..4c03fedfff --- /dev/null +++ b/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-and-class-modify/modules/x/cmp/cmp.js @@ -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') + } +} diff --git a/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-aria-modify/expected.html b/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-aria-modify/expected.html index d8797055d8..4e3c99df65 100644 --- a/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-aria-modify/expected.html +++ b/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-aria-modify/expected.html @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-modify-uppercase/expected.html b/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-modify-uppercase/expected.html new file mode 100644 index 0000000000..9dc2771bea --- /dev/null +++ b/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-modify-uppercase/expected.html @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-modify-uppercase/index.js b/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-modify-uppercase/index.js new file mode 100644 index 0000000000..55d4302e11 --- /dev/null +++ b/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-modify-uppercase/index.js @@ -0,0 +1,3 @@ +export const tagName = 'x-cmp'; +export { default } from 'x/cmp'; +export * from 'x/cmp'; diff --git a/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-modify-uppercase/modules/x/cmp/cmp.html b/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-modify-uppercase/modules/x/cmp/cmp.html new file mode 100644 index 0000000000..6beff5199f --- /dev/null +++ b/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-modify-uppercase/modules/x/cmp/cmp.html @@ -0,0 +1,2 @@ + diff --git a/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-modify-uppercase/modules/x/cmp/cmp.js b/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-modify-uppercase/modules/x/cmp/cmp.js new file mode 100644 index 0000000000..2edf814605 --- /dev/null +++ b/packages/@lwc/engine-server/src/__tests__/fixtures/attribute-modify-uppercase/modules/x/cmp/cmp.js @@ -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') + } +} diff --git a/packages/@lwc/engine-server/src/__tests__/fixtures/getter-class-list/expected.html b/packages/@lwc/engine-server/src/__tests__/fixtures/getter-class-list/expected.html index 2a4b83b560..f5e5d8382b 100644 --- a/packages/@lwc/engine-server/src/__tests__/fixtures/getter-class-list/expected.html +++ b/packages/@lwc/engine-server/src/__tests__/fixtures/getter-class-list/expected.html @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/packages/@lwc/engine-server/src/__tests__/fixtures/method-remove-attribute/expected.html b/packages/@lwc/engine-server/src/__tests__/fixtures/method-remove-attribute/expected.html index d1fa3aecec..478484fac5 100644 --- a/packages/@lwc/engine-server/src/__tests__/fixtures/method-remove-attribute/expected.html +++ b/packages/@lwc/engine-server/src/__tests__/fixtures/method-remove-attribute/expected.html @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/packages/@lwc/engine-server/src/__tests__/fixtures/method-set-attribute/expected.html b/packages/@lwc/engine-server/src/__tests__/fixtures/method-set-attribute/expected.html index aeab94a8b5..145ceae755 100644 --- a/packages/@lwc/engine-server/src/__tests__/fixtures/method-set-attribute/expected.html +++ b/packages/@lwc/engine-server/src/__tests__/fixtures/method-set-attribute/expected.html @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/packages/@lwc/engine-server/src/renderer.ts b/packages/@lwc/engine-server/src/renderer.ts index 02be1757e5..49b7a817ca 100644 --- a/packages/@lwc/engine-server/src/renderer.ts +++ b/packages/@lwc/engine-server/src/renderer.ts @@ -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 ); @@ -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 ); @@ -305,7 +305,7 @@ function getClassList(element: E) { return { add(...names: string[]): void { - reportMutation(element); + reportMutation(element, 'class'); const classAttribute = getClassAttribute(); const tokenList = classNameToTokenList(classAttribute.value); @@ -313,7 +313,7 @@ function getClassList(element: E) { classAttribute.value = tokenListToClassName(tokenList); }, remove(...names: string[]): void { - reportMutation(element); + reportMutation(element, 'class'); const classAttribute = getClassAttribute(); const tokenList = classNameToTokenList(classAttribute.value); diff --git a/packages/@lwc/engine-server/src/utils/mutation-tracking.ts b/packages/@lwc/engine-server/src/utils/mutation-tracking.ts index c4fb81d583..40395c02b1 100644 --- a/packages/@lwc/engine-server/src/utils/mutation-tracking.ts +++ b/packages/@lwc/engine-server/src/utils/mutation-tracking.ts @@ -10,16 +10,25 @@ const elementsToTrackForMutations: WeakSet = 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, }); } } diff --git a/packages/@lwc/integration-karma/test-hydration/mismatches/host-mutation-in-connected-callback/attr-mutated-class-mismatch/index.spec.js b/packages/@lwc/integration-karma/test-hydration/mismatches/host-mutation-in-connected-callback/attr-mutated-class-mismatch/index.spec.js new file mode 100644 index 0000000000..2bc184ea6b --- /dev/null +++ b/packages/@lwc/integration-karma/test-hydration/mismatches/host-mutation-in-connected-callback/attr-mutated-class-mismatch/index.spec.js @@ -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 : attribute "class" has different values, expected "is-client" but found "is-server"', + 'Hydration completed with errors.', + ], + }); + }, +}; diff --git a/packages/@lwc/integration-karma/test-hydration/mismatches/host-mutation-in-connected-callback/attr-mutated-class-mismatch/x/child/child.html b/packages/@lwc/integration-karma/test-hydration/mismatches/host-mutation-in-connected-callback/attr-mutated-class-mismatch/x/child/child.html new file mode 100644 index 0000000000..b54169fa06 --- /dev/null +++ b/packages/@lwc/integration-karma/test-hydration/mismatches/host-mutation-in-connected-callback/attr-mutated-class-mismatch/x/child/child.html @@ -0,0 +1,3 @@ + diff --git a/packages/@lwc/integration-karma/test-hydration/mismatches/host-mutation-in-connected-callback/attr-mutated-class-mismatch/x/child/child.js b/packages/@lwc/integration-karma/test-hydration/mismatches/host-mutation-in-connected-callback/attr-mutated-class-mismatch/x/child/child.js new file mode 100644 index 0000000000..e9c7cbaa9c --- /dev/null +++ b/packages/@lwc/integration-karma/test-hydration/mismatches/host-mutation-in-connected-callback/attr-mutated-class-mismatch/x/child/child.js @@ -0,0 +1,9 @@ +import { LightningElement } from 'lwc'; + +export default class Child extends LightningElement { + yolo = 'woot'; + + connectedCallback() { + this.setAttribute('data-mutatis', 'mutandis'); + } +} diff --git a/packages/@lwc/integration-karma/test-hydration/mismatches/host-mutation-in-connected-callback/attr-mutated-class-mismatch/x/main/main.html b/packages/@lwc/integration-karma/test-hydration/mismatches/host-mutation-in-connected-callback/attr-mutated-class-mismatch/x/main/main.html new file mode 100644 index 0000000000..3c9cf33c02 --- /dev/null +++ b/packages/@lwc/integration-karma/test-hydration/mismatches/host-mutation-in-connected-callback/attr-mutated-class-mismatch/x/main/main.html @@ -0,0 +1,3 @@ + diff --git a/packages/@lwc/integration-karma/test-hydration/mismatches/host-mutation-in-connected-callback/attr-mutated-class-mismatch/x/main/main.js b/packages/@lwc/integration-karma/test-hydration/mismatches/host-mutation-in-connected-callback/attr-mutated-class-mismatch/x/main/main.js new file mode 100644 index 0000000000..aacd9d30f5 --- /dev/null +++ b/packages/@lwc/integration-karma/test-hydration/mismatches/host-mutation-in-connected-callback/attr-mutated-class-mismatch/x/main/main.js @@ -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'; + } +} diff --git a/packages/@lwc/integration-karma/test-hydration/mismatches/host-mutation-in-connected-callback/class-mutated-attr-mismatch/index.spec.js b/packages/@lwc/integration-karma/test-hydration/mismatches/host-mutation-in-connected-callback/class-mutated-attr-mismatch/index.spec.js new file mode 100644 index 0000000000..7d7bdbc20e --- /dev/null +++ b/packages/@lwc/integration-karma/test-hydration/mismatches/host-mutation-in-connected-callback/class-mutated-attr-mismatch/index.spec.js @@ -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 : attribute "data-mismatched-attr" has different values, expected "is-client" but found "is-server"', + 'Hydration completed with errors.', + ], + }); + }, +}; diff --git a/packages/@lwc/integration-karma/test-hydration/mismatches/host-mutation-in-connected-callback/class-mutated-attr-mismatch/x/child/child.html b/packages/@lwc/integration-karma/test-hydration/mismatches/host-mutation-in-connected-callback/class-mutated-attr-mismatch/x/child/child.html new file mode 100644 index 0000000000..b54169fa06 --- /dev/null +++ b/packages/@lwc/integration-karma/test-hydration/mismatches/host-mutation-in-connected-callback/class-mutated-attr-mismatch/x/child/child.html @@ -0,0 +1,3 @@ + diff --git a/packages/@lwc/integration-karma/test-hydration/mismatches/host-mutation-in-connected-callback/class-mutated-attr-mismatch/x/child/child.js b/packages/@lwc/integration-karma/test-hydration/mismatches/host-mutation-in-connected-callback/class-mutated-attr-mismatch/x/child/child.js new file mode 100644 index 0000000000..92e1a94698 --- /dev/null +++ b/packages/@lwc/integration-karma/test-hydration/mismatches/host-mutation-in-connected-callback/class-mutated-attr-mismatch/x/child/child.js @@ -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'); + } +} diff --git a/packages/@lwc/integration-karma/test-hydration/mismatches/host-mutation-in-connected-callback/class-mutated-attr-mismatch/x/main/main.html b/packages/@lwc/integration-karma/test-hydration/mismatches/host-mutation-in-connected-callback/class-mutated-attr-mismatch/x/main/main.html new file mode 100644 index 0000000000..f53301c9d0 --- /dev/null +++ b/packages/@lwc/integration-karma/test-hydration/mismatches/host-mutation-in-connected-callback/class-mutated-attr-mismatch/x/main/main.html @@ -0,0 +1,3 @@ + diff --git a/packages/@lwc/integration-karma/test-hydration/mismatches/host-mutation-in-connected-callback/class-mutated-attr-mismatch/x/main/main.js b/packages/@lwc/integration-karma/test-hydration/mismatches/host-mutation-in-connected-callback/class-mutated-attr-mismatch/x/main/main.js new file mode 100644 index 0000000000..05fbc211ee --- /dev/null +++ b/packages/@lwc/integration-karma/test-hydration/mismatches/host-mutation-in-connected-callback/class-mutated-attr-mismatch/x/main/main.js @@ -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'; + } +}