From 9cd06b2ba48ecaae3e7b78478a71c639b5535045 Mon Sep 17 00:00:00 2001 From: Jose David Rodriguez Velasco Date: Tue, 27 Jul 2021 10:00:39 -0700 Subject: [PATCH 01/12] fix: call wa update method on connect, even when it has dynamic params --- packages/@lwc/engine-core/src/framework/wiring.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/@lwc/engine-core/src/framework/wiring.ts b/packages/@lwc/engine-core/src/framework/wiring.ts index 5bee413900..8cb3339f51 100644 --- a/packages/@lwc/engine-core/src/framework/wiring.ts +++ b/packages/@lwc/engine-core/src/framework/wiring.ts @@ -338,6 +338,7 @@ export function installWireAdapters(vm: VM) { wireDef ); const hasDynamicParams = wireDef.dynamic.length > 0; + ArrayPush.call(wiredConnecting, () => { connector.connect(); if (!featureFlags.ENABLE_WIRE_SYNC_EMIT) { From 950b042d1e3e0149bb98a93be7f1eb412cf06fa0 Mon Sep 17 00:00:00 2001 From: Jose David Rodriguez Velasco Date: Tue, 27 Jul 2021 17:28:26 -0700 Subject: [PATCH 02/12] feat: rehydrate rendered node from server --- .../babel-plugin-component/src/constants.js | 1 + .../src/3rdparty/snabbdom/types.ts | 1 + .../@lwc/engine-core/src/framework/api.ts | 52 +++++++++++++++++- .../@lwc/engine-core/src/framework/hooks.ts | 13 +++++ .../@lwc/engine-core/src/framework/main.ts | 1 + packages/@lwc/engine-core/src/framework/vm.ts | 41 ++++++++++++++ .../engine-dom/src/apis/hydrate-component.ts | 54 +++++++++++++++++++ packages/@lwc/engine-dom/src/index.ts | 1 + packages/@lwc/engine-dom/src/renderer.ts | 9 ++++ 9 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 packages/@lwc/engine-dom/src/apis/hydrate-component.ts diff --git a/packages/@lwc/babel-plugin-component/src/constants.js b/packages/@lwc/babel-plugin-component/src/constants.js index 277b62a900..3c301c1b4c 100644 --- a/packages/@lwc/babel-plugin-component/src/constants.js +++ b/packages/@lwc/babel-plugin-component/src/constants.js @@ -47,6 +47,7 @@ const LWC_SUPPORTED_APIS = new Set([ 'unwrap', // From "@lwc/engine-dom" + 'hydrateComponent', 'buildCustomElementConstructor', 'createElement', diff --git a/packages/@lwc/engine-core/src/3rdparty/snabbdom/types.ts b/packages/@lwc/engine-core/src/3rdparty/snabbdom/types.ts index 5f32763559..09c99f95f2 100644 --- a/packages/@lwc/engine-core/src/3rdparty/snabbdom/types.ts +++ b/packages/@lwc/engine-core/src/3rdparty/snabbdom/types.ts @@ -94,6 +94,7 @@ export interface Hooks { move: (vNode: N, parentNode: Node, referenceNode: Node | null) => void; update: (oldVNode: N, vNode: N) => void; remove: (vNode: N, parentNode: Node) => void; + hydrate: (vNode: N, node: Node) => void; } export interface Module { diff --git a/packages/@lwc/engine-core/src/framework/api.ts b/packages/@lwc/engine-core/src/framework/api.ts index f8161158d6..84c264c2ba 100644 --- a/packages/@lwc/engine-core/src/framework/api.ts +++ b/packages/@lwc/engine-core/src/framework/api.ts @@ -24,7 +24,7 @@ import { toString, } from '@lwc/shared'; import { logError } from '../shared/logger'; -import { RenderMode } from './vm'; +import { RenderMode, getComponentInternalDef } from './vm'; import { invokeEventListener } from './invoker'; import { getVMBeingRendered } from './template'; import { EmptyArray, EmptyObject } from './utils'; @@ -39,6 +39,8 @@ import { VM, VMState, getRenderRoot, + createVM, + hydrateVM, } from './vm'; import { VNode, @@ -66,6 +68,7 @@ import { updateChildrenHook, allocateChildrenHook, markAsDynamicChildren, + hydrateElementChildrenHook, } from './hooks'; import { isComponentConstructor } from './def'; import { getUpgradableConstructor } from './upgradable-element'; @@ -86,6 +89,9 @@ const TextHook: Hooks = { insert: insertNodeHook, move: insertNodeHook, // same as insert for text nodes remove: removeNodeHook, + hydrate: (vNode, node) => { + vNode.elm = node; + }, }; const CommentHook: Hooks = { @@ -101,6 +107,9 @@ const CommentHook: Hooks = { insert: insertNodeHook, move: insertNodeHook, // same as insert for text nodes remove: removeNodeHook, + hydrate: (vNode, node) => { + vNode.elm = node; + }, }; // insert is called after update, which is used somewhere else (via a module) @@ -141,6 +150,14 @@ const ElementHook: Hooks = { removeNodeHook(vnode, parentNode); removeElmHook(vnode); }, + hydrate: (vnode, node) => { + vnode.elm = node as Element; + + createElmHook(vnode); + + // hydrate children hook + hydrateElementChildrenHook(vnode); + }, }; const CustomElementHook: Hooks = { @@ -219,6 +236,39 @@ const CustomElementHook: Hooks = { removeVM(vm); } }, + hydrate: (vnode, elm) => { + // the element is created, but the vm is not + const { sel, mode, ctor, owner } = vnode; + + const def = getComponentInternalDef(ctor); + createVM(elm, def, { + mode, + owner, + tagName: sel, + renderer: owner.renderer, + }); + + vnode.elm = elm as Element; + + const vm = getAssociatedVMIfPresent(elm); + if (vm) { + allocateChildrenHook(vnode, vm); + } + + createCustomElmHook(vnode); + + // Insert hook section: + if (vm) { + if (process.env.NODE_ENV !== 'production') { + assert.isTrue(vm.state === VMState.created, `${vm} cannot be recycled.`); + } + runConnectedCallback(vm); + } + hydrateElementChildrenHook(vnode); + if (vm) { + hydrateVM(vm); + } + }, }; function linkNodeToShadow(elm: Node, owner: VM) { diff --git a/packages/@lwc/engine-core/src/framework/hooks.ts b/packages/@lwc/engine-core/src/framework/hooks.ts index ce3eb80acf..32616d2181 100644 --- a/packages/@lwc/engine-core/src/framework/hooks.ts +++ b/packages/@lwc/engine-core/src/framework/hooks.ts @@ -240,6 +240,19 @@ export function createChildrenHook(vnode: VElement) { } } +export function hydrateElementChildrenHook(vnode: VElement) { + const { elm, children } = vnode; + const elmChildren = elm!.childNodes; + let elmCurrentChildIdx = 0; + for (let j = 0, n = children.length; j < n; j++) { + const ch = children[j]; + if (ch != null) { + ch.hook.hydrate(ch, elmChildren[elmCurrentChildIdx]); + elmCurrentChildIdx++; + } + } +} + export function updateCustomElmHook(oldVnode: VCustomElement, vnode: VCustomElement) { // Attrs need to be applied to element before props // IE11 will wipe out value on radio inputs if value diff --git a/packages/@lwc/engine-core/src/framework/main.ts b/packages/@lwc/engine-core/src/framework/main.ts index 5e5cbbfb55..6954286176 100644 --- a/packages/@lwc/engine-core/src/framework/main.ts +++ b/packages/@lwc/engine-core/src/framework/main.ts @@ -24,6 +24,7 @@ export { connectRootElement, disconnectRootElement, getAssociatedVMIfPresent, + hydrateRootElement, } from './vm'; // Internal APIs used by compiled code ------------------------------------------------------------- diff --git a/packages/@lwc/engine-core/src/framework/vm.ts b/packages/@lwc/engine-core/src/framework/vm.ts index 02905c9d44..7faa444fb7 100644 --- a/packages/@lwc/engine-core/src/framework/vm.ts +++ b/packages/@lwc/engine-core/src/framework/vm.ts @@ -202,6 +202,19 @@ export function connectRootElement(elm: any) { logGlobalOperationEnd(OperationId.GlobalHydrate, vm); } +export function hydrateRootElement(elm: any) { + const vm = getAssociatedVM(elm); + + // Usually means moving the element from one place to another, which is observable via + // life-cycle hooks. + if (vm.state === VMState.connected) { + disconnectRootElement(elm); + } + + runConnectedCallback(vm); + hydrateVM(vm); +} + export function disconnectRootElement(elm: any) { const vm = getAssociatedVM(elm); resetComponentStateWhenRemoved(vm); @@ -211,6 +224,10 @@ export function appendVM(vm: VM) { rehydrate(vm); } +export function hydrateVM(vm: VM) { + hydrate(vm); +} + // just in case the component comes back, with this we guarantee re-rendering it // while preventing any attempt to rehydration until after reinsertion. function resetComponentStateWhenRemoved(vm: VM) { @@ -409,6 +426,30 @@ function rehydrate(vm: VM) { } } +function hydrate(vm: VM) { + if (isTrue(vm.isDirty)) { + // manually diffing/patching here. + // This routine is: + // patchShadowRoot(vm, children); + // -> addVnodes. + const children = renderComponent(vm); + const element = vm.elm; + vm.children = children; + + const elementChildren = element.shadowRoot.childNodes; + let elementCurrentChildIdx = 0; + + for (let i = 0, n = children.length; i < n; i++) { + const ch = children[i]; + // ifs may generate null vnodes. + if (ch != null) { + ch!.hook.hydrate(ch, elementChildren[elementCurrentChildIdx]); + elementCurrentChildIdx++; + } + } + } +} + function patchShadowRoot(vm: VM, newCh: VNodes) { const { children: oldCh } = vm; diff --git a/packages/@lwc/engine-dom/src/apis/hydrate-component.ts b/packages/@lwc/engine-dom/src/apis/hydrate-component.ts new file mode 100644 index 0000000000..f92c618f88 --- /dev/null +++ b/packages/@lwc/engine-dom/src/apis/hydrate-component.ts @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2018, 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 + */ + +import { + createVM, + getComponentInternalDef, + LightningElement, + hydrateRootElement, +} from '@lwc/engine-core'; +import { isFunction, isNull, isObject } from '@lwc/shared'; +import { renderer, setIsHydrating } from '../renderer'; + +export function hydrateComponent( + element: Element, + Ctor: typeof LightningElement, + props: { [name: string]: any } = {} +) { + if (!isFunction(Ctor)) { + throw new TypeError( + `"hydrateComponent" expects a valid component constructor as the second parameter but instead received ${Ctor}.` + ); + } + + if (!isObject(props) || isNull(props)) { + throw new TypeError( + `"hydrateComponent" expects an object as the third parameter but instead received ${props}.` + ); + } + + const def = getComponentInternalDef(Ctor); + + // For now, an honest hack so it does not replace the existing shadowRoot in renderer.attachShadow + setIsHydrating(true); + + createVM(element, def, { + mode: 'open', + owner: null, + renderer, + tagName: element.tagName.toLowerCase(), + }); + + for (const [key, value] of Object.entries(props)) { + (element as any)[key] = value; + } + + hydrateRootElement(element); + + // set it back since now we finished hydration. + setIsHydrating(false); +} diff --git a/packages/@lwc/engine-dom/src/index.ts b/packages/@lwc/engine-dom/src/index.ts index 3a02e014e8..11a69f0741 100644 --- a/packages/@lwc/engine-dom/src/index.ts +++ b/packages/@lwc/engine-dom/src/index.ts @@ -33,6 +33,7 @@ export { } from '@lwc/engine-core'; // Engine-dom public APIs -------------------------------------------------------------------------- +export { hydrateComponent } from './apis/hydrate-component'; export { deprecatedBuildCustomElementConstructor as buildCustomElementConstructor } from './apis/build-custom-element-constructor'; export { createElement } from './apis/create-element'; export { getComponentConstructor } from './apis/get-component-constructor'; diff --git a/packages/@lwc/engine-dom/src/renderer.ts b/packages/@lwc/engine-dom/src/renderer.ts index c23e5f0aec..55b0630b4d 100644 --- a/packages/@lwc/engine-dom/src/renderer.ts +++ b/packages/@lwc/engine-dom/src/renderer.ts @@ -143,6 +143,12 @@ if (isCustomElementRegistryAvailable()) { HTMLElementConstructor.prototype = HTMLElement.prototype; } +let isHydrating = false; + +export function setIsHydrating(v: boolean) { + isHydrating = v; +} + export const renderer: Renderer = { ssr: false, @@ -176,6 +182,9 @@ export const renderer: Renderer = { }, attachShadow(element: Element, options: ShadowRootInit): ShadowRoot { + if (isHydrating) { + return element.shadowRoot!; + } return element.attachShadow(options); }, From 1acc27e6239cda06830bd6b2eb1bd62e2ae98f7a Mon Sep 17 00:00:00 2001 From: Jose David Rodriguez Velasco Date: Thu, 26 Aug 2021 18:10:12 -0400 Subject: [PATCH 03/12] Revert "feat: enable wire adapters in ssr" This reverts commit fd007d514bca687bad88b2f84ee5e18c014dfaa8. --- packages/@lwc/engine-core/src/framework/vm.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@lwc/engine-core/src/framework/vm.ts b/packages/@lwc/engine-core/src/framework/vm.ts index 7faa444fb7..869c723b55 100644 --- a/packages/@lwc/engine-core/src/framework/vm.ts +++ b/packages/@lwc/engine-core/src/framework/vm.ts @@ -343,7 +343,7 @@ export function createVM( invokeComponentConstructor(vm, def.ctor); // Initializing the wire decorator per instance only when really needed - if (hasWireAdapters(vm)) { + if (isFalse(renderer.ssr) && hasWireAdapters(vm)) { installWireAdapters(vm); } From c26c3042f4adb911c07e633060da44a46346dc08 Mon Sep 17 00:00:00 2001 From: Jose David Rodriguez Velasco Date: Thu, 26 Aug 2021 18:55:54 -0400 Subject: [PATCH 04/12] perf: improve hydrating elments by only set events and props --- packages/@lwc/engine-core/src/framework/api.ts | 14 ++++++-------- .../@lwc/engine-core/src/framework/hooks.ts | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/packages/@lwc/engine-core/src/framework/api.ts b/packages/@lwc/engine-core/src/framework/api.ts index 84c264c2ba..abfe5d208e 100644 --- a/packages/@lwc/engine-core/src/framework/api.ts +++ b/packages/@lwc/engine-core/src/framework/api.ts @@ -69,6 +69,8 @@ import { allocateChildrenHook, markAsDynamicChildren, hydrateElementChildrenHook, + hydrateNodeHook, + hydrateElmHook, } from './hooks'; import { isComponentConstructor } from './def'; import { getUpgradableConstructor } from './upgradable-element'; @@ -89,9 +91,7 @@ const TextHook: Hooks = { insert: insertNodeHook, move: insertNodeHook, // same as insert for text nodes remove: removeNodeHook, - hydrate: (vNode, node) => { - vNode.elm = node; - }, + hydrate: hydrateNodeHook, }; const CommentHook: Hooks = { @@ -107,9 +107,7 @@ const CommentHook: Hooks = { insert: insertNodeHook, move: insertNodeHook, // same as insert for text nodes remove: removeNodeHook, - hydrate: (vNode, node) => { - vNode.elm = node; - }, + hydrate: hydrateNodeHook, }; // insert is called after update, which is used somewhere else (via a module) @@ -153,7 +151,7 @@ const ElementHook: Hooks = { hydrate: (vnode, node) => { vnode.elm = node as Element; - createElmHook(vnode); + hydrateElmHook(vnode); // hydrate children hook hydrateElementChildrenHook(vnode); @@ -255,7 +253,7 @@ const CustomElementHook: Hooks = { allocateChildrenHook(vnode, vm); } - createCustomElmHook(vnode); + hydrateElmHook(vnode); // Insert hook section: if (vm) { diff --git a/packages/@lwc/engine-core/src/framework/hooks.ts b/packages/@lwc/engine-core/src/framework/hooks.ts index 32616d2181..03980f1fd5 100644 --- a/packages/@lwc/engine-core/src/framework/hooks.ts +++ b/packages/@lwc/engine-core/src/framework/hooks.ts @@ -44,6 +44,11 @@ function setScopeTokenClassIfNecessary(elm: Element, owner: VM) { } } +export function hydrateNodeHook(vNode: VNode, node: Node) { + vNode.elm = node; + +} + export function updateNodeHook(oldVnode: VNode, vnode: VNode) { const { elm, @@ -103,6 +108,18 @@ const enum LWCDOMMode { manual = 'manual', } +export function hydrateElmHook(vnode: VElement) { + modEvents.create(vnode); + // Attrs are already on the element. + // modAttrs.create(vnode); + modProps.create(vnode); + // Already set. + // modStaticClassName.create(vnode); + // modStaticStyle.create(vnode); + // modComputedClassName.create(vnode); + // modComputedStyle.create(vnode); +} + export function fallbackElmHook(elm: Element, vnode: VElement) { const { owner } = vnode; setScopeTokenClassIfNecessary(elm, owner); From c99e8beda3095f3954976a2cce0d684b99bca247 Mon Sep 17 00:00:00 2001 From: Jose David Rodriguez Velasco Date: Mon, 20 Sep 2021 16:21:24 -0400 Subject: [PATCH 05/12] feat: bailout mechanism, run renderedCallback This commit adds: - Bailout mechanism when hydrating an element. - Adds logic to match an element and a vnode to know if it can be hydrated. - Runs the renderedCallback (missing from previous commits) --- .../@lwc/engine-core/src/framework/api.ts | 42 ++++- .../@lwc/engine-core/src/framework/hooks.ts | 174 +++++++++++++++++- packages/@lwc/engine-core/src/framework/vm.ts | 16 +- .../engine-dom/src/apis/hydrate-component.ts | 49 +++-- 4 files changed, 243 insertions(+), 38 deletions(-) diff --git a/packages/@lwc/engine-core/src/framework/api.ts b/packages/@lwc/engine-core/src/framework/api.ts index abfe5d208e..5d2ce8836c 100644 --- a/packages/@lwc/engine-core/src/framework/api.ts +++ b/packages/@lwc/engine-core/src/framework/api.ts @@ -68,8 +68,7 @@ import { updateChildrenHook, allocateChildrenHook, markAsDynamicChildren, - hydrateElementChildrenHook, - hydrateNodeHook, + hydrateChildrenHook, hydrateElmHook, } from './hooks'; import { isComponentConstructor } from './def'; @@ -91,7 +90,26 @@ const TextHook: Hooks = { insert: insertNodeHook, move: insertNodeHook, // same as insert for text nodes remove: removeNodeHook, - hydrate: hydrateNodeHook, + hydrate: (vNode: VNode, node: Node) => { + // @todo tests. + if (process.env.NODE_ENV !== 'production') { + if (node.nodeType !== Node.TEXT_NODE) { + logError('Hydration mismatch: incorrect node type received', vNode.owner); + assert.fail('Hydration mismatch: incorrect node type received.'); + } + + if (node.nodeValue !== vNode.text) { + logError( + 'Hydration mismatch: text values do not match, will recover from the difference', + vNode.owner + ); + } + } + + // always set the text value to the one from the vnode. + node.nodeValue = vNode.text ?? null; + vNode.elm = node; + }, }; const CommentHook: Hooks = { @@ -107,7 +125,19 @@ const CommentHook: Hooks = { insert: insertNodeHook, move: insertNodeHook, // same as insert for text nodes remove: removeNodeHook, - hydrate: hydrateNodeHook, + hydrate: (vNode: VNode, node: Node) => { + // @todo tests. + if (process.env.NODE_ENV !== 'production') { + if (node.nodeType !== Node.COMMENT_NODE) { + logError('Hydration mismatch: incorrect node type received', vNode.owner); + assert.fail('Hydration mismatch: incorrect node type received.'); + } + } + + // always set the text value to the one from the vnode. + node.nodeValue = vNode.text ?? null; + vNode.elm = node; + }, }; // insert is called after update, which is used somewhere else (via a module) @@ -154,7 +184,7 @@ const ElementHook: Hooks = { hydrateElmHook(vnode); // hydrate children hook - hydrateElementChildrenHook(vnode); + hydrateChildrenHook(vnode.elm.childNodes, vnode.children, vnode.owner); }, }; @@ -262,7 +292,7 @@ const CustomElementHook: Hooks = { } runConnectedCallback(vm); } - hydrateElementChildrenHook(vnode); + hydrateChildrenHook(vnode.elm.childNodes, vnode.children, vm); if (vm) { hydrateVM(vm); } diff --git a/packages/@lwc/engine-core/src/framework/hooks.ts b/packages/@lwc/engine-core/src/framework/hooks.ts index 03980f1fd5..1eeab51496 100644 --- a/packages/@lwc/engine-core/src/framework/hooks.ts +++ b/packages/@lwc/engine-core/src/framework/hooks.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: MIT * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT */ -import { assert, isArray, isNull, isUndefined, noop } from '@lwc/shared'; +import { ArrayFilter, assert, isArray, isNull, isUndefined, noop } from '@lwc/shared'; import { EmptyArray } from './utils'; import { createVM, @@ -26,6 +26,7 @@ import modStaticStyle from './modules/static-style-attr'; import { updateDynamicChildren, updateStaticChildren } from '../3rdparty/snabbdom/snabbdom'; import { patchElementWithRestrictions, unlockDomMutation, lockDomMutation } from './restrictions'; import { getComponentInternalDef } from './def'; +import { logError } from '../shared/logger'; function observeElementChildNodes(elm: Element) { (elm as any).$domManual$ = true; @@ -46,7 +47,6 @@ function setScopeTokenClassIfNecessary(elm: Element, owner: VM) { export function hydrateNodeHook(vNode: VNode, node: Node) { vNode.elm = node; - } export function updateNodeHook(oldVnode: VNode, vnode: VNode) { @@ -257,14 +257,176 @@ export function createChildrenHook(vnode: VElement) { } } -export function hydrateElementChildrenHook(vnode: VElement) { - const { elm, children } = vnode; - const elmChildren = elm!.childNodes; +function isElementNode(node: ChildNode): node is Element { + return node.nodeType === Node.ELEMENT_NODE; +} + +function vnodesAndElementHaveCompatibleAttrs(vnode: VNode, elm: Element): boolean { + const { + data: { attrs = {} }, + owner: { renderer }, + } = vnode; + + let nodesAreCompatible = true; + + // Validate attributes, though we could always recovery from those by running the update mods. + // Note: intentionally ONLY matching vnodes.attrs to elm.attrs, in case SSR is adding extra attributes. + for (const [attrName, attrValue] of Object.entries(attrs)) { + const elmAttrValue = renderer.getAttribute(elm, attrName); + if (attrValue !== elmAttrValue) { + logError( + `Error hydrating element: attribute "${attrName}" has different values, expected "${attrValue}" but found "${elmAttrValue}"`, + vnode.owner + ); + nodesAreCompatible = false; + } + } + + return nodesAreCompatible; +} + +function vnodesAndElementHaveCompatibleClass(vnode: VNode, elm: Element): boolean { + const { + data: { className, classMap }, + owner: { renderer }, + } = vnode; + + let nodesAreCompatible = true; + + if (!isUndefined(className) && className !== elm.className) { + // @todo: not sure if the above comparison is correct, maybe we should normalize to classlist + // className is used when class is bound to an expr. + logError( + `Mismatch hydrating element: attribute "class" has different values, expected "${className}" but found "${elm.className}"`, + vnode.owner + ); + nodesAreCompatible = false; + } else if (!isUndefined(classMap)) { + // classMap is used when class is set to static value. + // @todo: there might be a simpler method to do this. + const classList = renderer.getClassList(elm); + let hasClassMismatch = false; + let computedClassName = ''; + + // all classes from the vnode should be in the element.classList + for (const name in classMap) { + computedClassName += ' ' + name; + if (!classList.contains(name)) { + nodesAreCompatible = false; + hasClassMismatch = true; + } + } + + // all classes from element.classList should be in the vnode classMap + classList.forEach((name) => { + if (!classMap[name]) { + nodesAreCompatible = false; + hasClassMismatch = true; + } + }); + + if (hasClassMismatch) { + logError( + `Mismatch hydrating element: attribute "class" has different values, expected "${computedClassName.trim()}" but found "${ + elm.className + }"`, + vnode.owner + ); + } + } + + return nodesAreCompatible; +} + +function vnodesAndElementHaveCompatibleStyle(vnode: VNode, elm: Element): boolean { + const { + data: { style, styleMap }, + owner: { renderer }, + } = vnode; + const elmStyle = renderer.getAttribute(elm, 'style'); + let nodesAreCompatible = true; + + // @todo: question: would it be the same or is there a chance that the browser tweak the result of elm.setAttribute('style', ...)? + // ex: such "str" exist that after elm.setAttribute('style', str), elm.getAttribute('style') !== str. + if (!isUndefined(style) && style !== elmStyle) { + // style is used when class is bound to an expr. + logError( + `Mismatch hydrating element: attribute "style" has different values, expected "${style}" but found "${elmStyle}".`, + vnode.owner + ); + nodesAreCompatible = false; + } else if (!isUndefined(styleMap)) { + // styleMap is used when class is set to static value. + for (const name in styleMap) { + // @todo: this probably needs to have its own renderer method. + const elmStyleProp = (elm as HTMLElement).style.getPropertyValue(name); + if (styleMap[name] !== elmStyleProp) { + nodesAreCompatible = false; + } + } + + // questions: is there a way to check that only those props in styleMap are set in the element? + // how to generate the style? + logError('Error hydrating element: attribute "style" has different values.', vnode.owner); + } + + return nodesAreCompatible; +} + +function throwHydrationError() { + // @todo: maybe create a type for these type of hydration errors + assert.fail('Server rendered elements do not match client side generated elements'); +} + +export function hydrateChildrenHook(elmChildren: NodeListOf, children: VNodes, vm?: VM) { + if (process.env.NODE_ENV !== 'production') { + const filteredVNodes = ArrayFilter.call(children, (vnode) => !!vnode); + + if (elmChildren.length !== filteredVNodes.length) { + logError( + `Hydration mismatch: incorrect number of rendered elements, expected ${filteredVNodes.length} but found ${elmChildren.length}.`, + vm + ); + throwHydrationError(); + } + } + let elmCurrentChildIdx = 0; for (let j = 0, n = children.length; j < n; j++) { const ch = children[j]; if (ch != null) { - ch.hook.hydrate(ch, elmChildren[elmCurrentChildIdx]); + const childNode = elmChildren[elmCurrentChildIdx]; + + if (process.env.NODE_ENV !== 'production') { + // VComments and VTexts validation is handled in their hooks + if (isElementNode(childNode)) { + if (ch.sel?.toLowerCase() !== childNode.tagName.toLowerCase()) { + logError( + `Hydration mismatch: expecting element with tag "${ch.sel}" but found "${childNode.tagName}".`, + vm + ); + + throwHydrationError(); + } + + // Note: props are not yet set + const isVNodeAndElementCompatible = + vnodesAndElementHaveCompatibleAttrs(ch, childNode) && + vnodesAndElementHaveCompatibleClass(ch, childNode) && + vnodesAndElementHaveCompatibleStyle(ch, childNode); + + if (!isVNodeAndElementCompatible) { + logError( + `Hydration mismatch: incompatible attributes for element with tag "${childNode.tagName}".`, + vm + ); + + throwHydrationError(); + } + } + } + + ch.hook.hydrate(ch, childNode); elmCurrentChildIdx++; } } diff --git a/packages/@lwc/engine-core/src/framework/vm.ts b/packages/@lwc/engine-core/src/framework/vm.ts index 869c723b55..0dc72b0f64 100644 --- a/packages/@lwc/engine-core/src/framework/vm.ts +++ b/packages/@lwc/engine-core/src/framework/vm.ts @@ -34,7 +34,7 @@ import { logGlobalOperationEnd, logGlobalOperationStart, } from './profiler'; -import { hasDynamicChildren } from './hooks'; +import { hasDynamicChildren, hydrateChildrenHook } from './hooks'; import { ReactiveObserver } from './mutation-tracker'; import { connectWireAdapters, disconnectWireAdapters, installWireAdapters } from './wiring'; import { AccessorReactiveObserver } from './decorators/api'; @@ -213,6 +213,7 @@ export function hydrateRootElement(elm: any) { runConnectedCallback(vm); hydrateVM(vm); + // should we hydrate the root children? light dom case. } export function disconnectRootElement(elm: any) { @@ -433,20 +434,11 @@ function hydrate(vm: VM) { // patchShadowRoot(vm, children); // -> addVnodes. const children = renderComponent(vm); - const element = vm.elm; vm.children = children; - const elementChildren = element.shadowRoot.childNodes; - let elementCurrentChildIdx = 0; + hydrateChildrenHook(vm.elm.shadowRoot.childNodes, children, vm); - for (let i = 0, n = children.length; i < n; i++) { - const ch = children[i]; - // ifs may generate null vnodes. - if (ch != null) { - ch!.hook.hydrate(ch, elementChildren[elementCurrentChildIdx]); - elementCurrentChildIdx++; - } - } + runRenderedCallback(vm); } } diff --git a/packages/@lwc/engine-dom/src/apis/hydrate-component.ts b/packages/@lwc/engine-dom/src/apis/hydrate-component.ts index f92c618f88..7ef2be17fc 100644 --- a/packages/@lwc/engine-dom/src/apis/hydrate-component.ts +++ b/packages/@lwc/engine-dom/src/apis/hydrate-component.ts @@ -13,6 +13,7 @@ import { } from '@lwc/engine-core'; import { isFunction, isNull, isObject } from '@lwc/shared'; import { renderer, setIsHydrating } from '../renderer'; +import { createElement } from './create-element'; export function hydrateComponent( element: Element, @@ -33,22 +34,42 @@ export function hydrateComponent( const def = getComponentInternalDef(Ctor); - // For now, an honest hack so it does not replace the existing shadowRoot in renderer.attachShadow - setIsHydrating(true); + try { + // For now, an honest hack so it does not replace the existing shadowRoot in renderer.attachShadow + setIsHydrating(true); - createVM(element, def, { - mode: 'open', - owner: null, - renderer, - tagName: element.tagName.toLowerCase(), - }); + createVM(element, def, { + mode: 'open', + owner: null, + renderer, + tagName: element.tagName.toLowerCase(), + }); - for (const [key, value] of Object.entries(props)) { - (element as any)[key] = value; - } + for (const [key, value] of Object.entries(props)) { + (element as any)[key] = value; + } + + hydrateRootElement(element); + + // set it back since now we finished hydration. + setIsHydrating(false); + } catch (e) { + // Fallback: In case there's an error while hydrating, let's log the error, and replace the element with + // the client generated DOM. - hydrateRootElement(element); + /* eslint-disable-next-line no-console */ + console.error(e); - // set it back since now we finished hydration. - setIsHydrating(false); + setIsHydrating(false); + const newElem = createElement(element.tagName, { + is: Ctor, + mode: 'open', + }); + + for (const [key, value] of Object.entries(props)) { + (newElem as any)[key] = value; + } + + element.parentNode!.replaceChild(newElem, element); + } } From c7428740646f540508a85903ee444099b762db5a Mon Sep 17 00:00:00 2001 From: Jose David Rodriguez Velasco Date: Fri, 24 Sep 2021 13:36:29 -0400 Subject: [PATCH 06/12] feat: handle light dom components --- packages/@lwc/engine-core/src/framework/api.ts | 8 +++++++- packages/@lwc/engine-core/src/framework/vm.ts | 4 +++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/@lwc/engine-core/src/framework/api.ts b/packages/@lwc/engine-core/src/framework/api.ts index 5d2ce8836c..007c3545d1 100644 --- a/packages/@lwc/engine-core/src/framework/api.ts +++ b/packages/@lwc/engine-core/src/framework/api.ts @@ -292,7 +292,13 @@ const CustomElementHook: Hooks = { } runConnectedCallback(vm); } - hydrateChildrenHook(vnode.elm.childNodes, vnode.children, vm); + + if (!(vm && vm.renderMode === RenderMode.Light)) { + // VM is not rendering in Light DOM, we can proceed and hydrate the slotted content. + // Note: for Light DOM, this is handled while hydrating the VM + hydrateChildrenHook(vnode.elm.childNodes, vnode.children, vm); + } + if (vm) { hydrateVM(vm); } diff --git a/packages/@lwc/engine-core/src/framework/vm.ts b/packages/@lwc/engine-core/src/framework/vm.ts index 0dc72b0f64..2e2e2f5329 100644 --- a/packages/@lwc/engine-core/src/framework/vm.ts +++ b/packages/@lwc/engine-core/src/framework/vm.ts @@ -436,7 +436,9 @@ function hydrate(vm: VM) { const children = renderComponent(vm); vm.children = children; - hydrateChildrenHook(vm.elm.shadowRoot.childNodes, children, vm); + const vmChildren = + vm.renderMode === RenderMode.Light ? vm.elm.childNodes : vm.elm.shadowRoot.childNodes; + hydrateChildrenHook(vmChildren, children, vm); runRenderedCallback(vm); } From d3af37e9ef43156f2d5c75b9097c4f917b04b2b2 Mon Sep 17 00:00:00 2001 From: Jose David Rodriguez Velasco Date: Mon, 27 Sep 2021 10:37:46 -0400 Subject: [PATCH 07/12] refactor: rebase from master --- packages/@lwc/engine-core/src/framework/api.ts | 6 ++++-- packages/@lwc/engine-core/src/framework/hooks.ts | 15 +++++++++------ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/@lwc/engine-core/src/framework/api.ts b/packages/@lwc/engine-core/src/framework/api.ts index 007c3545d1..a4e9039b7a 100644 --- a/packages/@lwc/engine-core/src/framework/api.ts +++ b/packages/@lwc/engine-core/src/framework/api.ts @@ -24,7 +24,6 @@ import { toString, } from '@lwc/shared'; import { logError } from '../shared/logger'; -import { RenderMode, getComponentInternalDef } from './vm'; import { invokeEventListener } from './invoker'; import { getVMBeingRendered } from './template'; import { EmptyArray, EmptyObject } from './utils'; @@ -41,6 +40,7 @@ import { getRenderRoot, createVM, hydrateVM, + RenderMode, } from './vm'; import { VNode, @@ -71,7 +71,7 @@ import { hydrateChildrenHook, hydrateElmHook, } from './hooks'; -import { isComponentConstructor } from './def'; +import { getComponentInternalDef, isComponentConstructor } from './def'; import { getUpgradableConstructor } from './upgradable-element'; const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'; @@ -93,6 +93,7 @@ const TextHook: Hooks = { hydrate: (vNode: VNode, node: Node) => { // @todo tests. if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line lwc-internal/no-global-node if (node.nodeType !== Node.TEXT_NODE) { logError('Hydration mismatch: incorrect node type received', vNode.owner); assert.fail('Hydration mismatch: incorrect node type received.'); @@ -128,6 +129,7 @@ const CommentHook: Hooks = { hydrate: (vNode: VNode, node: Node) => { // @todo tests. if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line lwc-internal/no-global-node if (node.nodeType !== Node.COMMENT_NODE) { logError('Hydration mismatch: incorrect node type received', vNode.owner); assert.fail('Hydration mismatch: incorrect node type received.'); diff --git a/packages/@lwc/engine-core/src/framework/hooks.ts b/packages/@lwc/engine-core/src/framework/hooks.ts index 1eeab51496..77449c7924 100644 --- a/packages/@lwc/engine-core/src/framework/hooks.ts +++ b/packages/@lwc/engine-core/src/framework/hooks.ts @@ -258,6 +258,8 @@ export function createChildrenHook(vnode: VElement) { } function isElementNode(node: ChildNode): node is Element { + // @todo: should the hydrate be part of engine-dom? can we move hydrate out of the hooks? + // eslint-disable-next-line lwc-internal/no-global-node return node.nodeType === Node.ELEMENT_NODE; } @@ -340,7 +342,7 @@ function vnodesAndElementHaveCompatibleClass(vnode: VNode, elm: Element): boolea function vnodesAndElementHaveCompatibleStyle(vnode: VNode, elm: Element): boolean { const { - data: { style, styleMap }, + data: { style, styleDecls }, owner: { renderer }, } = vnode; const elmStyle = renderer.getAttribute(elm, 'style'); @@ -355,12 +357,13 @@ function vnodesAndElementHaveCompatibleStyle(vnode: VNode, elm: Element): boolea vnode.owner ); nodesAreCompatible = false; - } else if (!isUndefined(styleMap)) { + } else if (!isUndefined(styleDecls)) { // styleMap is used when class is set to static value. - for (const name in styleMap) { - // @todo: this probably needs to have its own renderer method. - const elmStyleProp = (elm as HTMLElement).style.getPropertyValue(name); - if (styleMap[name] !== elmStyleProp) { + for (let i = 0; i < styleDecls.length; i++) { + const [prop, value, important] = styleDecls[i]; + const elmPropValue = (elm as HTMLElement).style.getPropertyValue(prop); + const elmPropPriority = (elm as HTMLElement).style.getPropertyPriority(prop); + if (value !== elmPropValue || important !== (elmPropPriority === 'important')) { nodesAreCompatible = false; } } From b64b99b891634e87f35329bbdc1101e799a22503 Mon Sep 17 00:00:00 2001 From: Jose David Rodriguez Velasco Date: Mon, 27 Sep 2021 11:40:46 -0400 Subject: [PATCH 08/12] feat: handle component styles during hydration --- packages/@lwc/engine-core/src/framework/renderer.ts | 1 + packages/@lwc/engine-core/src/framework/stylesheet.ts | 2 +- packages/@lwc/engine-dom/src/apis/hydrate-component.ts | 2 +- packages/@lwc/engine-dom/src/renderer.ts | 3 +++ packages/@lwc/engine-server/src/renderer.ts | 3 +++ 5 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/@lwc/engine-core/src/framework/renderer.ts b/packages/@lwc/engine-core/src/framework/renderer.ts index 676d797160..5024659df2 100644 --- a/packages/@lwc/engine-core/src/framework/renderer.ts +++ b/packages/@lwc/engine-core/src/framework/renderer.ts @@ -10,6 +10,7 @@ export type HostElement = any; export interface Renderer { ssr: boolean; + readonly isHydrating: boolean; isNativeShadowDefined: boolean; isSyntheticShadowDefined: boolean; insert(node: N, parent: E, anchor: N | null): void; diff --git a/packages/@lwc/engine-core/src/framework/stylesheet.ts b/packages/@lwc/engine-core/src/framework/stylesheet.ts index 89d875fa42..da75dd5ca4 100644 --- a/packages/@lwc/engine-core/src/framework/stylesheet.ts +++ b/packages/@lwc/engine-core/src/framework/stylesheet.ts @@ -164,7 +164,7 @@ export function createStylesheet(vm: VM, stylesheets: string[]): VNode | null { for (let i = 0; i < stylesheets.length; i++) { renderer.insertGlobalStylesheet(stylesheets[i]); } - } else if (renderer.ssr) { + } else if (renderer.ssr || renderer.isHydrating) { // native shadow or light DOM, SSR const combinedStylesheetContent = ArrayJoin.call(stylesheets, '\n'); return createInlineStyleVNode(combinedStylesheetContent); diff --git a/packages/@lwc/engine-dom/src/apis/hydrate-component.ts b/packages/@lwc/engine-dom/src/apis/hydrate-component.ts index 7ef2be17fc..8cafc7e57d 100644 --- a/packages/@lwc/engine-dom/src/apis/hydrate-component.ts +++ b/packages/@lwc/engine-dom/src/apis/hydrate-component.ts @@ -58,7 +58,7 @@ export function hydrateComponent( // the client generated DOM. /* eslint-disable-next-line no-console */ - console.error(e); + console.error('Recovering from error while hydrating: ', e); setIsHydrating(false); const newElem = createElement(element.tagName, { diff --git a/packages/@lwc/engine-dom/src/renderer.ts b/packages/@lwc/engine-dom/src/renderer.ts index 55b0630b4d..9945458375 100644 --- a/packages/@lwc/engine-dom/src/renderer.ts +++ b/packages/@lwc/engine-dom/src/renderer.ts @@ -151,6 +151,9 @@ export function setIsHydrating(v: boolean) { export const renderer: Renderer = { ssr: false, + get isHydrating(): boolean { + return isHydrating; + }, isNativeShadowDefined: globalThis[KEY__IS_NATIVE_SHADOW_ROOT_DEFINED], isSyntheticShadowDefined: hasOwnProperty.call(Element.prototype, KEY__SHADOW_TOKEN), diff --git a/packages/@lwc/engine-server/src/renderer.ts b/packages/@lwc/engine-server/src/renderer.ts index 09622c3b05..825240bc93 100644 --- a/packages/@lwc/engine-server/src/renderer.ts +++ b/packages/@lwc/engine-server/src/renderer.ts @@ -61,6 +61,9 @@ class HTMLElement { export const renderer: Renderer = { ssr: true, + get isHydrating(): boolean { + return false; + }, isNativeShadowDefined: false, isSyntheticShadowDefined: false, From 294a31d978c293acc39b0e4f3c8afeb90d5d4322 Mon Sep 17 00:00:00 2001 From: Jose David Rodriguez Velasco Date: Tue, 28 Sep 2021 10:28:40 -0400 Subject: [PATCH 09/12] fix: wire adapters in ssr missing because of rebase --- packages/@lwc/engine-core/src/framework/vm.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@lwc/engine-core/src/framework/vm.ts b/packages/@lwc/engine-core/src/framework/vm.ts index 2e2e2f5329..3ec259a332 100644 --- a/packages/@lwc/engine-core/src/framework/vm.ts +++ b/packages/@lwc/engine-core/src/framework/vm.ts @@ -344,7 +344,7 @@ export function createVM( invokeComponentConstructor(vm, def.ctor); // Initializing the wire decorator per instance only when really needed - if (isFalse(renderer.ssr) && hasWireAdapters(vm)) { + if (hasWireAdapters(vm)) { installWireAdapters(vm); } From b866028fb0cc04d6f0a71733dc8370204d5d1819 Mon Sep 17 00:00:00 2001 From: Jose David Rodriguez Velasco Date: Fri, 22 Oct 2021 14:14:52 -0400 Subject: [PATCH 10/12] wip: comments cleanup --- packages/@lwc/engine-core/src/framework/api.ts | 2 -- packages/@lwc/engine-core/src/framework/stylesheet.ts | 4 ++++ packages/@lwc/engine-core/src/framework/vm.ts | 7 ------- packages/@lwc/engine-core/src/framework/wiring.ts | 1 - packages/@lwc/engine-dom/src/apis/hydrate-component.ts | 3 ++- 5 files changed, 6 insertions(+), 11 deletions(-) diff --git a/packages/@lwc/engine-core/src/framework/api.ts b/packages/@lwc/engine-core/src/framework/api.ts index a4e9039b7a..12eb43a18d 100644 --- a/packages/@lwc/engine-core/src/framework/api.ts +++ b/packages/@lwc/engine-core/src/framework/api.ts @@ -91,7 +91,6 @@ const TextHook: Hooks = { move: insertNodeHook, // same as insert for text nodes remove: removeNodeHook, hydrate: (vNode: VNode, node: Node) => { - // @todo tests. if (process.env.NODE_ENV !== 'production') { // eslint-disable-next-line lwc-internal/no-global-node if (node.nodeType !== Node.TEXT_NODE) { @@ -185,7 +184,6 @@ const ElementHook: Hooks = { hydrateElmHook(vnode); - // hydrate children hook hydrateChildrenHook(vnode.elm.childNodes, vnode.children, vnode.owner); }, }; diff --git a/packages/@lwc/engine-core/src/framework/stylesheet.ts b/packages/@lwc/engine-core/src/framework/stylesheet.ts index da75dd5ca4..cce5500923 100644 --- a/packages/@lwc/engine-core/src/framework/stylesheet.ts +++ b/packages/@lwc/engine-core/src/framework/stylesheet.ts @@ -165,6 +165,10 @@ export function createStylesheet(vm: VM, stylesheets: string[]): VNode | null { renderer.insertGlobalStylesheet(stylesheets[i]); } } else if (renderer.ssr || renderer.isHydrating) { + // Note: We need to ensure that during hydration, the stylesheets method is the same as those in ssr. + // This works in the client, because the stylesheets are created, and cached in the VM + // the first time the VM renders. + // native shadow or light DOM, SSR const combinedStylesheetContent = ArrayJoin.call(stylesheets, '\n'); return createInlineStyleVNode(combinedStylesheetContent); diff --git a/packages/@lwc/engine-core/src/framework/vm.ts b/packages/@lwc/engine-core/src/framework/vm.ts index 3ec259a332..6bef3c62a4 100644 --- a/packages/@lwc/engine-core/src/framework/vm.ts +++ b/packages/@lwc/engine-core/src/framework/vm.ts @@ -205,15 +205,8 @@ export function connectRootElement(elm: any) { export function hydrateRootElement(elm: any) { const vm = getAssociatedVM(elm); - // Usually means moving the element from one place to another, which is observable via - // life-cycle hooks. - if (vm.state === VMState.connected) { - disconnectRootElement(elm); - } - runConnectedCallback(vm); hydrateVM(vm); - // should we hydrate the root children? light dom case. } export function disconnectRootElement(elm: any) { diff --git a/packages/@lwc/engine-core/src/framework/wiring.ts b/packages/@lwc/engine-core/src/framework/wiring.ts index 8cb3339f51..5bee413900 100644 --- a/packages/@lwc/engine-core/src/framework/wiring.ts +++ b/packages/@lwc/engine-core/src/framework/wiring.ts @@ -338,7 +338,6 @@ export function installWireAdapters(vm: VM) { wireDef ); const hasDynamicParams = wireDef.dynamic.length > 0; - ArrayPush.call(wiredConnecting, () => { connector.connect(); if (!featureFlags.ENABLE_WIRE_SYNC_EMIT) { diff --git a/packages/@lwc/engine-dom/src/apis/hydrate-component.ts b/packages/@lwc/engine-dom/src/apis/hydrate-component.ts index 8cafc7e57d..2eafd3ec09 100644 --- a/packages/@lwc/engine-dom/src/apis/hydrate-component.ts +++ b/packages/@lwc/engine-dom/src/apis/hydrate-component.ts @@ -35,7 +35,8 @@ export function hydrateComponent( const def = getComponentInternalDef(Ctor); try { - // For now, an honest hack so it does not replace the existing shadowRoot in renderer.attachShadow + // Let the renderer know we are hydrating, so it does not replace the existing shadowRoot + // and uses the same algo to create the stylesheets as in SSR. setIsHydrating(true); createVM(element, def, { From be9d929cbddf73a6d815daceba386ce4c4974ed9 Mon Sep 17 00:00:00 2001 From: Jose David Rodriguez Velasco Date: Fri, 22 Oct 2021 15:52:29 -0400 Subject: [PATCH 11/12] fix: handle lwc:inner-html during hydration --- packages/@lwc/engine-core/src/framework/api.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/@lwc/engine-core/src/framework/api.ts b/packages/@lwc/engine-core/src/framework/api.ts index 12eb43a18d..665634938b 100644 --- a/packages/@lwc/engine-core/src/framework/api.ts +++ b/packages/@lwc/engine-core/src/framework/api.ts @@ -184,7 +184,12 @@ const ElementHook: Hooks = { hydrateElmHook(vnode); - hydrateChildrenHook(vnode.elm.childNodes, vnode.children, vnode.owner); + const { context } = vnode.data; + const isDomManual = Boolean(context && context.lwc && context.lwc.dom === 'manual'); + + if (!isDomManual) { + hydrateChildrenHook(vnode.elm.childNodes, vnode.children, vnode.owner); + } }, }; From 0b31e4fa4f117fa89f088ae90fcccbb2de417141 Mon Sep 17 00:00:00 2001 From: Jose David Rodriguez Velasco Date: Tue, 26 Oct 2021 13:36:27 -0700 Subject: [PATCH 12/12] feat(integration-karma): hydration tests base build (#2541) * wip: test framework 1st checkpoint what i don't like: - the test (.spec) has cjs exports and needs a "def" variable, ideally, it should only need the export default and that's it. - it modifies existing lwc plugin, because it does need to be based on the output of pepe. wip: checkpoint 2, a lot better wip: checkpoint 3 wip: try run it in ci fix: ci fix: headers check fix: disable safari and firefox fix: bring back safari and firefox * wip: add hydration test commands to the readme * test(hydration): directives * test(hydration): component lifecycle * test: inner-html directive * wip: mismatch tests * wip: rename folder * fix: diff and reuse dom from lwc inner-html * wip: review feedback, better errors * refactor: use getAssociatedVM for hydration instead of getAssociatedVMIfPresent * fix: resolve todos * fix: display all attribute errors at once --- .circleci/config.yml | 9 + .../@lwc/engine-core/src/framework/api.ts | 63 ++++--- .../@lwc/engine-core/src/framework/hooks.ts | 120 +++++++------ .../@lwc/engine-core/src/framework/utils.ts | 22 +++ .../@lwc/engine-core/src/shared/logger.ts | 16 +- packages/integration-karma/README.md | 8 + .../integration-karma/helpers/test-hydrate.js | 67 +++++++ .../integration-karma/helpers/test-utils.js | 1 + packages/integration-karma/package.json | 3 + .../scripts/karma-configs/hydration-base.js | 88 ++++++++++ .../scripts/karma-configs/local.js | 4 +- .../scripts/karma-configs/sauce.js | 8 +- .../scripts/karma-plugins/Watcher.js | 37 ++++ .../scripts/karma-plugins/hydration-tests.js | 165 ++++++++++++++++++ .../scripts/karma-plugins/lwc.js | 31 +--- .../scripts/shared/options.js | 2 + .../connected-callback/index.spec.js | 15 ++ .../connected-callback/x/main/main.html | 3 + .../connected-callback/x/main/main.js | 8 + .../disconnected-callback/index.spec.js | 28 +++ .../disconnected-callback/x/foo/foo.html | 3 + .../disconnected-callback/x/foo/foo.js | 9 + .../disconnected-callback/x/main/main.html | 6 + .../disconnected-callback/x/main/main.js | 10 ++ .../render-method/index.spec.js | 24 +++ .../render-method/x/main/a.html | 3 + .../render-method/x/main/b.html | 3 + .../render-method/x/main/main.js | 11 ++ .../rendered-callback/index.spec.js | 19 ++ .../rendered-callback/x/main/main.html | 3 + .../rendered-callback/x/main/main.js | 8 + .../directives/comments/index.spec.js | 26 +++ .../directives/comments/x/main/main.html | 7 + .../directives/comments/x/main/main.js | 5 + .../directives/dom-manual/index.spec.js | 13 ++ .../directives/dom-manual/x/main/main.html | 3 + .../directives/dom-manual/x/main/main.js | 7 + .../directives/for-each/index.spec.js | 31 ++++ .../directives/for-each/x/main/main.html | 7 + .../directives/for-each/x/main/main.js | 5 + .../directives/if-false/index.spec.js | 19 ++ .../directives/if-false/x/main/main.html | 5 + .../directives/if-false/x/main/main.js | 5 + .../directives/if-true/index.spec.js | 19 ++ .../directives/if-true/x/main/main.html | 5 + .../directives/if-true/x/main/main.js | 5 + .../directives/iterator/index.spec.js | 31 ++++ .../directives/iterator/x/main/main.html | 7 + .../directives/iterator/x/main/main.js | 5 + .../directives/lwc-dynamic/index.spec.js | 22 +++ .../directives/lwc-dynamic/x/child/child.html | 3 + .../directives/lwc-dynamic/x/child/child.js | 5 + .../directives/lwc-dynamic/x/main/main.html | 3 + .../directives/lwc-dynamic/x/main/main.js | 7 + .../directives/lwc-inner-html/index.spec.js | 22 +++ .../lwc-inner-html/x/main/main.html | 3 + .../directives/lwc-inner-html/x/main/main.js | 5 + .../attrs-compatibility/index.spec.js | 30 ++++ .../attrs-compatibility/x/main/main.html | 18 ++ .../attrs-compatibility/x/main/main.js | 5 + .../dynamic-different/index.spec.js | 27 +++ .../dynamic-different/x/main/main.html | 3 + .../dynamic-different/x/main/main.js | 5 + .../index.spec.js | 27 +++ .../x/main/main.html | 3 + .../x/main/main.js | 5 + .../class-attr/dynamic-same/index.spec.js | 18 ++ .../class-attr/dynamic-same/x/main/main.html | 3 + .../class-attr/dynamic-same/x/main/main.js | 5 + .../index.spec.js | 28 +++ .../x/main/main.html | 8 + .../x/main/main.js | 5 + .../index.spec.js | 28 +++ .../x/main/main.html | 8 + .../x/main/main.js | 5 + .../static-same-different-order/index.spec.js | 21 +++ .../x/main/main.html | 8 + .../x/main/main.js | 5 + .../comment-instead-of-text/index.spec.js | 26 +++ .../comment-instead-of-text/x/main/main.html | 4 + .../comment-instead-of-text/x/main/main.js | 5 + .../different-lwc-inner-html/index.spec.js | 35 ++++ .../different-lwc-inner-html/x/main/main.html | 3 + .../different-lwc-inner-html/x/main/main.js | 5 + .../index.spec.js | 38 ++++ .../x/main/main.html | 3 + .../x/main/main.js | 7 + .../favors-client-side-comment/index.spec.js | 26 +++ .../x/main/main.html | 4 + .../favors-client-side-comment/x/main/main.js | 5 + .../favors-client-side-text/index.spec.js | 26 +++ .../favors-client-side-text/x/main/main.html | 3 + .../favors-client-side-text/x/main/main.js | 5 + .../preserve-ssr-attr/index.spec.js | 22 +++ .../preserve-ssr-attr/x/main/main.html | 15 ++ .../preserve-ssr-attr/x/main/main.js | 5 + .../static-different-priority/index.spec.js | 30 ++++ .../x/main/main.html | 8 + .../static-different-priority/x/main/main.js | 5 + .../static-extra-from-client/index.spec.js | 30 ++++ .../static-extra-from-client/x/main/main.html | 8 + .../static-extra-from-client/x/main/main.js | 5 + .../static-extra-from-server/index.spec.js | 28 +++ .../static-extra-from-server/x/main/main.html | 8 + .../static-extra-from-server/x/main/main.js | 5 + .../static-same-different-order/index.spec.js | 23 +++ .../x/main/main.html | 8 + .../x/main/main.js | 5 + .../static-same-priority/index.spec.js | 15 ++ .../static-same-priority/x/main/main.html | 4 + .../static-same-priority/x/main/main.js | 3 + .../style-attr/static-same/index.spec.js | 17 ++ .../style-attr/static-same/x/main/main.html | 3 + .../style-attr/static-same/x/main/main.js | 3 + .../text-instead-of-comment/index.spec.js | 26 +++ .../text-instead-of-comment/x/main/main.html | 4 + .../text-instead-of-comment/x/main/main.js | 5 + .../test-hydration/simple/index.spec.js | 24 +++ .../test-hydration/simple/x/main/main.html | 3 + .../test-hydration/simple/x/main/main.js | 5 + scripts/tasks/check-license-headers.js | 1 + 121 files changed, 1735 insertions(+), 115 deletions(-) create mode 100644 packages/integration-karma/helpers/test-hydrate.js create mode 100644 packages/integration-karma/scripts/karma-configs/hydration-base.js create mode 100644 packages/integration-karma/scripts/karma-plugins/Watcher.js create mode 100644 packages/integration-karma/scripts/karma-plugins/hydration-tests.js create mode 100644 packages/integration-karma/test-hydration/component-lifecycle/connected-callback/index.spec.js create mode 100644 packages/integration-karma/test-hydration/component-lifecycle/connected-callback/x/main/main.html create mode 100644 packages/integration-karma/test-hydration/component-lifecycle/connected-callback/x/main/main.js create mode 100644 packages/integration-karma/test-hydration/component-lifecycle/disconnected-callback/index.spec.js create mode 100644 packages/integration-karma/test-hydration/component-lifecycle/disconnected-callback/x/foo/foo.html create mode 100644 packages/integration-karma/test-hydration/component-lifecycle/disconnected-callback/x/foo/foo.js create mode 100644 packages/integration-karma/test-hydration/component-lifecycle/disconnected-callback/x/main/main.html create mode 100644 packages/integration-karma/test-hydration/component-lifecycle/disconnected-callback/x/main/main.js create mode 100644 packages/integration-karma/test-hydration/component-lifecycle/render-method/index.spec.js create mode 100644 packages/integration-karma/test-hydration/component-lifecycle/render-method/x/main/a.html create mode 100644 packages/integration-karma/test-hydration/component-lifecycle/render-method/x/main/b.html create mode 100644 packages/integration-karma/test-hydration/component-lifecycle/render-method/x/main/main.js create mode 100644 packages/integration-karma/test-hydration/component-lifecycle/rendered-callback/index.spec.js create mode 100644 packages/integration-karma/test-hydration/component-lifecycle/rendered-callback/x/main/main.html create mode 100644 packages/integration-karma/test-hydration/component-lifecycle/rendered-callback/x/main/main.js create mode 100644 packages/integration-karma/test-hydration/directives/comments/index.spec.js create mode 100644 packages/integration-karma/test-hydration/directives/comments/x/main/main.html create mode 100644 packages/integration-karma/test-hydration/directives/comments/x/main/main.js create mode 100644 packages/integration-karma/test-hydration/directives/dom-manual/index.spec.js create mode 100644 packages/integration-karma/test-hydration/directives/dom-manual/x/main/main.html create mode 100644 packages/integration-karma/test-hydration/directives/dom-manual/x/main/main.js create mode 100644 packages/integration-karma/test-hydration/directives/for-each/index.spec.js create mode 100644 packages/integration-karma/test-hydration/directives/for-each/x/main/main.html create mode 100644 packages/integration-karma/test-hydration/directives/for-each/x/main/main.js create mode 100644 packages/integration-karma/test-hydration/directives/if-false/index.spec.js create mode 100644 packages/integration-karma/test-hydration/directives/if-false/x/main/main.html create mode 100644 packages/integration-karma/test-hydration/directives/if-false/x/main/main.js create mode 100644 packages/integration-karma/test-hydration/directives/if-true/index.spec.js create mode 100644 packages/integration-karma/test-hydration/directives/if-true/x/main/main.html create mode 100644 packages/integration-karma/test-hydration/directives/if-true/x/main/main.js create mode 100644 packages/integration-karma/test-hydration/directives/iterator/index.spec.js create mode 100644 packages/integration-karma/test-hydration/directives/iterator/x/main/main.html create mode 100644 packages/integration-karma/test-hydration/directives/iterator/x/main/main.js create mode 100644 packages/integration-karma/test-hydration/directives/lwc-dynamic/index.spec.js create mode 100644 packages/integration-karma/test-hydration/directives/lwc-dynamic/x/child/child.html create mode 100644 packages/integration-karma/test-hydration/directives/lwc-dynamic/x/child/child.js create mode 100644 packages/integration-karma/test-hydration/directives/lwc-dynamic/x/main/main.html create mode 100644 packages/integration-karma/test-hydration/directives/lwc-dynamic/x/main/main.js create mode 100644 packages/integration-karma/test-hydration/directives/lwc-inner-html/index.spec.js create mode 100644 packages/integration-karma/test-hydration/directives/lwc-inner-html/x/main/main.html create mode 100644 packages/integration-karma/test-hydration/directives/lwc-inner-html/x/main/main.js create mode 100644 packages/integration-karma/test-hydration/mismatches/attrs-compatibility/index.spec.js create mode 100644 packages/integration-karma/test-hydration/mismatches/attrs-compatibility/x/main/main.html create mode 100644 packages/integration-karma/test-hydration/mismatches/attrs-compatibility/x/main/main.js create mode 100644 packages/integration-karma/test-hydration/mismatches/class-attr/dynamic-different/index.spec.js create mode 100644 packages/integration-karma/test-hydration/mismatches/class-attr/dynamic-different/x/main/main.html create mode 100644 packages/integration-karma/test-hydration/mismatches/class-attr/dynamic-different/x/main/main.js create mode 100644 packages/integration-karma/test-hydration/mismatches/class-attr/dynamic-same-different-order-throws/index.spec.js create mode 100644 packages/integration-karma/test-hydration/mismatches/class-attr/dynamic-same-different-order-throws/x/main/main.html create mode 100644 packages/integration-karma/test-hydration/mismatches/class-attr/dynamic-same-different-order-throws/x/main/main.js create mode 100644 packages/integration-karma/test-hydration/mismatches/class-attr/dynamic-same/index.spec.js create mode 100644 packages/integration-karma/test-hydration/mismatches/class-attr/dynamic-same/x/main/main.html create mode 100644 packages/integration-karma/test-hydration/mismatches/class-attr/dynamic-same/x/main/main.js create mode 100644 packages/integration-karma/test-hydration/mismatches/class-attr/static-extra-class-from-client/index.spec.js create mode 100644 packages/integration-karma/test-hydration/mismatches/class-attr/static-extra-class-from-client/x/main/main.html create mode 100644 packages/integration-karma/test-hydration/mismatches/class-attr/static-extra-class-from-client/x/main/main.js create mode 100644 packages/integration-karma/test-hydration/mismatches/class-attr/static-extra-class-from-server/index.spec.js create mode 100644 packages/integration-karma/test-hydration/mismatches/class-attr/static-extra-class-from-server/x/main/main.html create mode 100644 packages/integration-karma/test-hydration/mismatches/class-attr/static-extra-class-from-server/x/main/main.js create mode 100644 packages/integration-karma/test-hydration/mismatches/class-attr/static-same-different-order/index.spec.js create mode 100644 packages/integration-karma/test-hydration/mismatches/class-attr/static-same-different-order/x/main/main.html create mode 100644 packages/integration-karma/test-hydration/mismatches/class-attr/static-same-different-order/x/main/main.js create mode 100644 packages/integration-karma/test-hydration/mismatches/comment-instead-of-text/index.spec.js create mode 100644 packages/integration-karma/test-hydration/mismatches/comment-instead-of-text/x/main/main.html create mode 100644 packages/integration-karma/test-hydration/mismatches/comment-instead-of-text/x/main/main.js create mode 100644 packages/integration-karma/test-hydration/mismatches/different-lwc-inner-html/index.spec.js create mode 100644 packages/integration-karma/test-hydration/mismatches/different-lwc-inner-html/x/main/main.html create mode 100644 packages/integration-karma/test-hydration/mismatches/different-lwc-inner-html/x/main/main.js create mode 100644 packages/integration-karma/test-hydration/mismatches/display-errors-attrs-class-style/index.spec.js create mode 100644 packages/integration-karma/test-hydration/mismatches/display-errors-attrs-class-style/x/main/main.html create mode 100644 packages/integration-karma/test-hydration/mismatches/display-errors-attrs-class-style/x/main/main.js create mode 100644 packages/integration-karma/test-hydration/mismatches/favors-client-side-comment/index.spec.js create mode 100644 packages/integration-karma/test-hydration/mismatches/favors-client-side-comment/x/main/main.html create mode 100644 packages/integration-karma/test-hydration/mismatches/favors-client-side-comment/x/main/main.js create mode 100644 packages/integration-karma/test-hydration/mismatches/favors-client-side-text/index.spec.js create mode 100644 packages/integration-karma/test-hydration/mismatches/favors-client-side-text/x/main/main.html create mode 100644 packages/integration-karma/test-hydration/mismatches/favors-client-side-text/x/main/main.js create mode 100644 packages/integration-karma/test-hydration/mismatches/preserve-ssr-attr/index.spec.js create mode 100644 packages/integration-karma/test-hydration/mismatches/preserve-ssr-attr/x/main/main.html create mode 100644 packages/integration-karma/test-hydration/mismatches/preserve-ssr-attr/x/main/main.js create mode 100644 packages/integration-karma/test-hydration/mismatches/style-attr/static-different-priority/index.spec.js create mode 100644 packages/integration-karma/test-hydration/mismatches/style-attr/static-different-priority/x/main/main.html create mode 100644 packages/integration-karma/test-hydration/mismatches/style-attr/static-different-priority/x/main/main.js create mode 100644 packages/integration-karma/test-hydration/mismatches/style-attr/static-extra-from-client/index.spec.js create mode 100644 packages/integration-karma/test-hydration/mismatches/style-attr/static-extra-from-client/x/main/main.html create mode 100644 packages/integration-karma/test-hydration/mismatches/style-attr/static-extra-from-client/x/main/main.js create mode 100644 packages/integration-karma/test-hydration/mismatches/style-attr/static-extra-from-server/index.spec.js create mode 100644 packages/integration-karma/test-hydration/mismatches/style-attr/static-extra-from-server/x/main/main.html create mode 100644 packages/integration-karma/test-hydration/mismatches/style-attr/static-extra-from-server/x/main/main.js create mode 100644 packages/integration-karma/test-hydration/mismatches/style-attr/static-same-different-order/index.spec.js create mode 100644 packages/integration-karma/test-hydration/mismatches/style-attr/static-same-different-order/x/main/main.html create mode 100644 packages/integration-karma/test-hydration/mismatches/style-attr/static-same-different-order/x/main/main.js create mode 100644 packages/integration-karma/test-hydration/mismatches/style-attr/static-same-priority/index.spec.js create mode 100644 packages/integration-karma/test-hydration/mismatches/style-attr/static-same-priority/x/main/main.html create mode 100644 packages/integration-karma/test-hydration/mismatches/style-attr/static-same-priority/x/main/main.js create mode 100644 packages/integration-karma/test-hydration/mismatches/style-attr/static-same/index.spec.js create mode 100644 packages/integration-karma/test-hydration/mismatches/style-attr/static-same/x/main/main.html create mode 100644 packages/integration-karma/test-hydration/mismatches/style-attr/static-same/x/main/main.js create mode 100644 packages/integration-karma/test-hydration/mismatches/text-instead-of-comment/index.spec.js create mode 100644 packages/integration-karma/test-hydration/mismatches/text-instead-of-comment/x/main/main.html create mode 100644 packages/integration-karma/test-hydration/mismatches/text-instead-of-comment/x/main/main.js create mode 100644 packages/integration-karma/test-hydration/simple/index.spec.js create mode 100644 packages/integration-karma/test-hydration/simple/x/main/main.html create mode 100644 packages/integration-karma/test-hydration/simple/x/main/main.js diff --git a/.circleci/config.yml b/.circleci/config.yml index 4b77e7fe03..10254796ac 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -105,6 +105,9 @@ commands: type: boolean compat: type: boolean + test_hydrate: + type: boolean + default: false coverage: type: boolean default: true @@ -116,6 +119,7 @@ commands: <<# parameters.disable_synthetic >> DISABLE_SYNTHETIC=1 <> <<# parameters.force_native_shadow_mode >> FORCE_NATIVE_SHADOW_MODE_FOR_TEST=1 <> <<# parameters.compat >> COMPAT=1 <> + <<# parameters.test_hydrate >> TEST_HYDRATION=1 <> <<# parameters.coverage >> COVERAGE=1 <> yarn sauce @@ -190,6 +194,11 @@ jobs: disable_synthetic: false compat: false force_native_shadow_mode: true + - run_karma: + disable_synthetic: true + compat: false + force_native_shadow_mode: false + test_hydrate: true - run: name: Compute karma coverage command: yarn coverage diff --git a/packages/@lwc/engine-core/src/framework/api.ts b/packages/@lwc/engine-core/src/framework/api.ts index 665634938b..c0f13a0eee 100644 --- a/packages/@lwc/engine-core/src/framework/api.ts +++ b/packages/@lwc/engine-core/src/framework/api.ts @@ -23,13 +23,14 @@ import { StringReplace, toString, } from '@lwc/shared'; -import { logError } from '../shared/logger'; +import { logError, logWarn } from '../shared/logger'; import { invokeEventListener } from './invoker'; import { getVMBeingRendered } from './template'; import { EmptyArray, EmptyObject } from './utils'; import { appendVM, getAssociatedVMIfPresent, + getAssociatedVM, removeVM, rerenderVM, runConnectedCallback, @@ -70,6 +71,7 @@ import { markAsDynamicChildren, hydrateChildrenHook, hydrateElmHook, + LWCDOMMode, } from './hooks'; import { getComponentInternalDef, isComponentConstructor } from './def'; import { getUpgradableConstructor } from './upgradable-element'; @@ -99,7 +101,7 @@ const TextHook: Hooks = { } if (node.nodeValue !== vNode.text) { - logError( + logWarn( 'Hydration mismatch: text values do not match, will recover from the difference', vNode.owner ); @@ -126,13 +128,19 @@ const CommentHook: Hooks = { move: insertNodeHook, // same as insert for text nodes remove: removeNodeHook, hydrate: (vNode: VNode, node: Node) => { - // @todo tests. if (process.env.NODE_ENV !== 'production') { // eslint-disable-next-line lwc-internal/no-global-node if (node.nodeType !== Node.COMMENT_NODE) { logError('Hydration mismatch: incorrect node type received', vNode.owner); assert.fail('Hydration mismatch: incorrect node type received.'); } + + if (node.nodeValue !== vNode.text) { + logWarn( + 'Hydration mismatch: comment values do not match, will recover from the difference', + vNode.owner + ); + } } // always set the text value to the one from the vnode. @@ -180,12 +188,33 @@ const ElementHook: Hooks = { removeElmHook(vnode); }, hydrate: (vnode, node) => { - vnode.elm = node as Element; - - hydrateElmHook(vnode); + const elm = node as Element; + vnode.elm = elm; const { context } = vnode.data; - const isDomManual = Boolean(context && context.lwc && context.lwc.dom === 'manual'); + const isDomManual = Boolean( + !isUndefined(context) && + !isUndefined(context.lwc) && + context.lwc.dom === LWCDOMMode.manual + ); + + if (isDomManual) { + // it may be that this element has lwc:inner-html, we need to diff and in case are the same, + // remove the innerHTML from props so it reuses the existing dom elements. + const { props } = vnode.data; + if (!isUndefined(props) && !isUndefined(props.innerHTML)) { + if (elm.innerHTML === props.innerHTML) { + delete props.innerHTML; + } else { + logWarn( + `Mismatch hydrating element <${elm.tagName.toLowerCase()}>: innerHTML values do not match for element, will recover from the difference`, + vnode.owner + ); + } + } + } + + hydrateElmHook(vnode); if (!isDomManual) { hydrateChildrenHook(vnode.elm.childNodes, vnode.children, vnode.owner); @@ -283,30 +312,24 @@ const CustomElementHook: Hooks = { vnode.elm = elm as Element; - const vm = getAssociatedVMIfPresent(elm); - if (vm) { - allocateChildrenHook(vnode, vm); - } + const vm = getAssociatedVM(elm); + allocateChildrenHook(vnode, vm); hydrateElmHook(vnode); // Insert hook section: - if (vm) { - if (process.env.NODE_ENV !== 'production') { - assert.isTrue(vm.state === VMState.created, `${vm} cannot be recycled.`); - } - runConnectedCallback(vm); + if (process.env.NODE_ENV !== 'production') { + assert.isTrue(vm.state === VMState.created, `${vm} cannot be recycled.`); } + runConnectedCallback(vm); - if (!(vm && vm.renderMode === RenderMode.Light)) { + if (vm.renderMode !== RenderMode.Light) { // VM is not rendering in Light DOM, we can proceed and hydrate the slotted content. // Note: for Light DOM, this is handled while hydrating the VM hydrateChildrenHook(vnode.elm.childNodes, vnode.children, vm); } - if (vm) { - hydrateVM(vm); - } + hydrateVM(vm); }, }; diff --git a/packages/@lwc/engine-core/src/framework/hooks.ts b/packages/@lwc/engine-core/src/framework/hooks.ts index 77449c7924..e1951fedea 100644 --- a/packages/@lwc/engine-core/src/framework/hooks.ts +++ b/packages/@lwc/engine-core/src/framework/hooks.ts @@ -4,8 +4,17 @@ * SPDX-License-Identifier: MIT * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT */ -import { ArrayFilter, assert, isArray, isNull, isUndefined, noop } from '@lwc/shared'; -import { EmptyArray } from './utils'; +import { + ArrayFilter, + ArrayJoin, + assert, + isArray, + isNull, + isUndefined, + keys, + noop, +} from '@lwc/shared'; +import { EmptyArray, parseStyleText } from './utils'; import { createVM, allocateInSlot, @@ -45,10 +54,6 @@ function setScopeTokenClassIfNecessary(elm: Element, owner: VM) { } } -export function hydrateNodeHook(vNode: VNode, node: Node) { - vNode.elm = node; -} - export function updateNodeHook(oldVnode: VNode, vnode: VNode) { const { elm, @@ -104,7 +109,7 @@ export function createElmHook(vnode: VElement) { modComputedStyle.create(vnode); } -const enum LWCDOMMode { +export const enum LWCDOMMode { manual = 'manual', } @@ -258,7 +263,6 @@ export function createChildrenHook(vnode: VElement) { } function isElementNode(node: ChildNode): node is Element { - // @todo: should the hydrate be part of engine-dom? can we move hydrate out of the hooks? // eslint-disable-next-line lwc-internal/no-global-node return node.nodeType === Node.ELEMENT_NODE; } @@ -277,7 +281,7 @@ function vnodesAndElementHaveCompatibleAttrs(vnode: VNode, elm: Element): boolea const elmAttrValue = renderer.getAttribute(elm, attrName); if (attrValue !== elmAttrValue) { logError( - `Error hydrating element: attribute "${attrName}" has different values, expected "${attrValue}" but found "${elmAttrValue}"`, + `Mismatch hydrating element <${elm.tagName.toLowerCase()}>: attribute "${attrName}" has different values, expected "${attrValue}" but found "${elmAttrValue}"`, vnode.owner ); nodesAreCompatible = false; @@ -294,20 +298,15 @@ function vnodesAndElementHaveCompatibleClass(vnode: VNode, elm: Element): boolea } = vnode; let nodesAreCompatible = true; + let vnodeClassName; if (!isUndefined(className) && className !== elm.className) { - // @todo: not sure if the above comparison is correct, maybe we should normalize to classlist // className is used when class is bound to an expr. - logError( - `Mismatch hydrating element: attribute "class" has different values, expected "${className}" but found "${elm.className}"`, - vnode.owner - ); nodesAreCompatible = false; + vnodeClassName = className; } else if (!isUndefined(classMap)) { // classMap is used when class is set to static value. - // @todo: there might be a simpler method to do this. const classList = renderer.getClassList(elm); - let hasClassMismatch = false; let computedClassName = ''; // all classes from the vnode should be in the element.classList @@ -315,28 +314,25 @@ function vnodesAndElementHaveCompatibleClass(vnode: VNode, elm: Element): boolea computedClassName += ' ' + name; if (!classList.contains(name)) { nodesAreCompatible = false; - hasClassMismatch = true; } } - // all classes from element.classList should be in the vnode classMap - classList.forEach((name) => { - if (!classMap[name]) { - nodesAreCompatible = false; - hasClassMismatch = true; - } - }); + vnodeClassName = computedClassName.trim(); - if (hasClassMismatch) { - logError( - `Mismatch hydrating element: attribute "class" has different values, expected "${computedClassName.trim()}" but found "${ - elm.className - }"`, - vnode.owner - ); + if (classList.length > keys(classMap).length) { + nodesAreCompatible = false; } } + if (!nodesAreCompatible) { + logError( + `Mismatch hydrating element <${elm.tagName.toLowerCase()}>: attribute "class" has different values, expected "${vnodeClassName}" but found "${ + elm.className + }"`, + vnode.owner + ); + } + return nodesAreCompatible; } @@ -345,39 +341,51 @@ function vnodesAndElementHaveCompatibleStyle(vnode: VNode, elm: Element): boolea data: { style, styleDecls }, owner: { renderer }, } = vnode; - const elmStyle = renderer.getAttribute(elm, 'style'); + const elmStyle = renderer.getAttribute(elm, 'style') || ''; + let vnodeStyle; let nodesAreCompatible = true; - // @todo: question: would it be the same or is there a chance that the browser tweak the result of elm.setAttribute('style', ...)? - // ex: such "str" exist that after elm.setAttribute('style', str), elm.getAttribute('style') !== str. if (!isUndefined(style) && style !== elmStyle) { - // style is used when class is bound to an expr. - logError( - `Mismatch hydrating element: attribute "style" has different values, expected "${style}" but found "${elmStyle}".`, - vnode.owner - ); nodesAreCompatible = false; + vnodeStyle = style; } else if (!isUndefined(styleDecls)) { - // styleMap is used when class is set to static value. - for (let i = 0; i < styleDecls.length; i++) { + const parsedVnodeStyle = parseStyleText(elmStyle); + const expectedStyle = []; + // styleMap is used when style is set to static value. + for (let i = 0, n = styleDecls.length; i < n; i++) { const [prop, value, important] = styleDecls[i]; - const elmPropValue = (elm as HTMLElement).style.getPropertyValue(prop); - const elmPropPriority = (elm as HTMLElement).style.getPropertyPriority(prop); - if (value !== elmPropValue || important !== (elmPropPriority === 'important')) { + expectedStyle.push(`${prop}: ${value + (important ? ' important!' : '')}`); + + const parsedPropValue = parsedVnodeStyle[prop]; + + if (isUndefined(parsedPropValue)) { + nodesAreCompatible = false; + } else if (!parsedPropValue.startsWith(value)) { + nodesAreCompatible = false; + } else if (important && !parsedPropValue.endsWith('!important')) { nodesAreCompatible = false; } } - // questions: is there a way to check that only those props in styleMap are set in the element? - // how to generate the style? - logError('Error hydrating element: attribute "style" has different values.', vnode.owner); + if (keys(parsedVnodeStyle).length > styleDecls.length) { + nodesAreCompatible = false; + } + + vnodeStyle = ArrayJoin.call(expectedStyle, ';'); + } + + if (!nodesAreCompatible) { + // style is used when class is bound to an expr. + logError( + `Mismatch hydrating element <${elm.tagName.toLowerCase()}>: attribute "style" has different values, expected "${vnodeStyle}" but found "${elmStyle}".`, + vnode.owner + ); } return nodesAreCompatible; } function throwHydrationError() { - // @todo: maybe create a type for these type of hydration errors assert.fail('Server rendered elements do not match client side generated elements'); } @@ -387,7 +395,7 @@ export function hydrateChildrenHook(elmChildren: NodeListOf, children if (elmChildren.length !== filteredVNodes.length) { logError( - `Hydration mismatch: incorrect number of rendered elements, expected ${filteredVNodes.length} but found ${elmChildren.length}.`, + `Hydration mismatch: incorrect number of rendered nodes, expected ${filteredVNodes.length} but found ${elmChildren.length}.`, vm ); throwHydrationError(); @@ -405,7 +413,7 @@ export function hydrateChildrenHook(elmChildren: NodeListOf, children if (isElementNode(childNode)) { if (ch.sel?.toLowerCase() !== childNode.tagName.toLowerCase()) { logError( - `Hydration mismatch: expecting element with tag "${ch.sel}" but found "${childNode.tagName}".`, + `Hydration mismatch: expecting element with tag "${ch.sel?.toLowerCase()}" but found "${childNode.tagName.toLowerCase()}".`, vm ); @@ -413,17 +421,13 @@ export function hydrateChildrenHook(elmChildren: NodeListOf, children } // Note: props are not yet set + const hasIncompatibleAttrs = vnodesAndElementHaveCompatibleAttrs(ch, childNode); + const hasIncompatibleClass = vnodesAndElementHaveCompatibleClass(ch, childNode); + const hasIncompatibleStyle = vnodesAndElementHaveCompatibleStyle(ch, childNode); const isVNodeAndElementCompatible = - vnodesAndElementHaveCompatibleAttrs(ch, childNode) && - vnodesAndElementHaveCompatibleClass(ch, childNode) && - vnodesAndElementHaveCompatibleStyle(ch, childNode); + hasIncompatibleAttrs && hasIncompatibleClass && hasIncompatibleStyle; if (!isVNodeAndElementCompatible) { - logError( - `Hydration mismatch: incompatible attributes for element with tag "${childNode.tagName}".`, - vm - ); - throwHydrationError(); } } diff --git a/packages/@lwc/engine-core/src/framework/utils.ts b/packages/@lwc/engine-core/src/framework/utils.ts index cbd53afeeb..c70757139f 100644 --- a/packages/@lwc/engine-core/src/framework/utils.ts +++ b/packages/@lwc/engine-core/src/framework/utils.ts @@ -52,3 +52,25 @@ export function guid(): string { return s4() + s4() + '-' + s4() + '-' + s4() + '-' + s4() + '-' + s4() + s4() + s4(); } + +// Borrowed from Vue template compiler. +// https://github.com/vuejs/vue/blob/531371b818b0e31a989a06df43789728f23dc4e8/src/platforms/web/util/style.js#L5-L16 +const DECLARATION_DELIMITER = /;(?![^(]*\))/g; +const PROPERTY_DELIMITER = /:(.+)/; + +export function parseStyleText(cssText: string): { [name: string]: string } { + const styleMap: { [name: string]: string } = {}; + + const declarations = cssText.split(DECLARATION_DELIMITER); + for (const declaration of declarations) { + if (declaration) { + const [prop, value] = declaration.split(PROPERTY_DELIMITER); + + if (prop !== undefined && value !== undefined) { + styleMap[prop.trim()] = value.trim(); + } + } + } + + return styleMap; +} diff --git a/packages/@lwc/engine-core/src/shared/logger.ts b/packages/@lwc/engine-core/src/shared/logger.ts index a1ece69fe1..3aa2ec22fe 100644 --- a/packages/@lwc/engine-core/src/shared/logger.ts +++ b/packages/@lwc/engine-core/src/shared/logger.ts @@ -9,8 +9,8 @@ import { isUndefined } from '@lwc/shared'; import { VM } from '../framework/vm'; import { getComponentStack } from './format'; -export function logError(message: string, vm?: VM) { - let msg = `[LWC error]: ${message}`; +function log(method: 'warn' | 'error', message: string, vm?: VM) { + let msg = `[LWC ${method}]: ${message}`; if (!isUndefined(vm)) { msg = `${msg}\n${getComponentStack(vm)}`; @@ -18,7 +18,7 @@ export function logError(message: string, vm?: VM) { if (process.env.NODE_ENV === 'test') { /* eslint-disable-next-line no-console */ - console.error(msg); + console[method](msg); return; } @@ -26,6 +26,14 @@ export function logError(message: string, vm?: VM) { throw new Error(msg); } catch (e) { /* eslint-disable-next-line no-console */ - console.error(e); + console[method](e); } } + +export function logError(message: string, vm?: VM) { + log('error', message, vm); +} + +export function logWarn(message: string, vm?: VM) { + log('warn', message, vm); +} diff --git a/packages/integration-karma/README.md b/packages/integration-karma/README.md index b6375d912d..0375412b47 100644 --- a/packages/integration-karma/README.md +++ b/packages/integration-karma/README.md @@ -8,10 +8,18 @@ Karma integration test for `@lwc/compiler`, `@lwc/engine-dom`, and `@lwc/synthet Starts the Karma server in `watch` mode and start Google Chrome. Note that you can open different browsers to run the tests in parallel on all the browsers. While the server in running, updating a fixture will trigger the suite to run. +### `yarn start:hydration` + +Starts the Karma server in `watch` mode and start Google Chrome, to run the hydration test suite. Note that you can open different browsers to run the tests in parallel on all the browsers. While the server in running, updating a fixture will trigger the suite to run. + ### `yarn test` Run the test suite a single time on Google Chrome. +### `yarn test:hydration` + +Run the hydration test suite a single time on Google Chrome. + ### `yarn coverage` Combine the coverage produced by the different runs into a single coverage report. diff --git a/packages/integration-karma/helpers/test-hydrate.js b/packages/integration-karma/helpers/test-hydrate.js new file mode 100644 index 0000000000..9dedd320e2 --- /dev/null +++ b/packages/integration-karma/helpers/test-hydrate.js @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2018, 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 + */ + +window.HydrateTest = (function (lwc, testUtils) { + testUtils.setHooks({ + sanitizeHtmlContent: (content) => content, + }); + + const browserSupportsDeclarativeShadowDOM = Object.prototype.hasOwnProperty.call( + HTMLTemplateElement.prototype, + 'shadowRoot' + ); + + function polyfillDeclarativeShadowDom(root) { + root.querySelectorAll('template[shadowroot]').forEach((template) => { + const mode = template.getAttribute('shadowroot'); + const shadowRoot = template.parentNode.attachShadow({ mode }); + shadowRoot.appendChild(template.content); + template.remove(); + + polyfillDeclarativeShadowDom(shadowRoot); + }); + } + + function appendTestTarget(ssrtext) { + const div = document.createElement('div'); + const fragment = new DOMParser().parseFromString(ssrtext, 'text/html', { + includeShadowRoots: true, + }); + + const testTarget = fragment.querySelector('x-main'); + if (!browserSupportsDeclarativeShadowDOM) { + polyfillDeclarativeShadowDom(testTarget); + } + div.appendChild(testTarget); + + document.body.appendChild(div); + + return div; + } + + function runTest(ssrRendered, Component, testConfig) { + const container = appendTestTarget(ssrRendered); + let target = container.querySelector('x-main'); + + const snapshot = testConfig.snapshot ? testConfig.snapshot(target) : {}; + + const props = testConfig.props || {}; + const clientProps = testConfig.clientProps || props; + + const consoleSpy = testUtils.spyConsole(); + lwc.hydrateComponent(target, Component, clientProps); + consoleSpy.reset(); + + // let's select again the target, it should be the same elements as in the snapshot + target = container.querySelector('x-main'); + return testConfig.test(target, snapshot, consoleSpy.calls); + } + + return { + runTest, + }; +})(window.LWC, window.TestUtils); diff --git a/packages/integration-karma/helpers/test-utils.js b/packages/integration-karma/helpers/test-utils.js index db141d8259..39dc8dd2bc 100644 --- a/packages/integration-karma/helpers/test-utils.js +++ b/packages/integration-karma/helpers/test-utils.js @@ -387,5 +387,6 @@ window.TestUtils = (function (lwc, jasmine, beforeAll) { registerForLoad: registerForLoad, getHooks: getHooks, setHooks: setHooks, + spyConsole: spyConsole, }; })(LWC, jasmine, beforeAll); diff --git a/packages/integration-karma/package.json b/packages/integration-karma/package.json index f99ec8ccff..a482b2b4bc 100644 --- a/packages/integration-karma/package.json +++ b/packages/integration-karma/package.json @@ -7,6 +7,8 @@ "test": "karma start ./scripts/karma-configs/local.js --single-run --browsers Chrome", "test:compat": "COMPAT=1 yarn test", "test:native": "DISABLE_SYNTHETIC=1 yarn test", + "test:hydration": "TEST_HYDRATION=1 yarn test", + "start:hydration": "TEST_HYDRATION=1 yarn start", "sauce": "karma start ./scripts/karma-configs/sauce.js --single-run", "coverage": "node ./scripts/merge-coverage.js" }, @@ -15,6 +17,7 @@ "@lwc/engine-dom": "2.5.7", "@lwc/rollup-plugin": "2.5.7", "@lwc/synthetic-shadow": "2.5.7", + "@lwc/engine-server": "2.5.7", "chokidar": "^3.5.2", "istanbul-lib-coverage": "^3.0.1", "istanbul-lib-report": "^3.0.0", diff --git a/packages/integration-karma/scripts/karma-configs/hydration-base.js b/packages/integration-karma/scripts/karma-configs/hydration-base.js new file mode 100644 index 0000000000..715a7b3082 --- /dev/null +++ b/packages/integration-karma/scripts/karma-configs/hydration-base.js @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2018, 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 + */ + +'use strict'; + +const path = require('path'); +const { getModulePath } = require('lwc'); + +const karmaPluginEnv = require('../karma-plugins/env'); +const karmaPluginHydrationTests = require('../karma-plugins/hydration-tests'); +const { TAGS, GREP, COVERAGE } = require('../shared/options'); + +const BASE_DIR = path.resolve(__dirname, '../../test-hydration'); +const COVERAGE_DIR = path.resolve(__dirname, '../../coverage'); + +const LWC_ENGINE = getModulePath('engine-dom', 'iife', 'es2017', 'dev'); + +const TEST_UTILS = require.resolve('../../helpers/test-utils'); +const TEST_SETUP = require.resolve('../../helpers/test-setup'); +const TEST_HYDRATE = require.resolve('../../helpers/test-hydrate'); + +function createPattern(location, config = {}) { + return { + ...config, + pattern: location, + }; +} + +function getFiles() { + return [ + createPattern(LWC_ENGINE), + createPattern(TEST_SETUP), + createPattern(TEST_UTILS), + createPattern(TEST_HYDRATE), + createPattern('**/*.spec.js', { watched: false }), + ]; +} + +/** + * More details here: + * https://karma-runner.github.io/3.0/config/configuration-file.html + */ +module.exports = (config) => { + config.set({ + basePath: BASE_DIR, + files: getFiles(), + + // Transform all the spec files with the hydration-tests karma plugin. + preprocessors: { + '**/*.spec.js': ['hydration-tests'], + }, + + // Use the env plugin to inject the right environment variables into the app + // Use jasmine as test framework for the suite. + frameworks: ['env', 'jasmine'], + + // Specify what plugin should be registered by Karma. + plugins: ['karma-jasmine', karmaPluginHydrationTests, karmaPluginEnv], + + // Leave the reporter empty on purpose. Extending configuration need to pick the right reporter they want + // to use. + reporters: [], + + // Since the karma start command doesn't allow arguments passing, so we need to pass the grep arg manually. + // The grep flag is consumed at runtime by jasmine to filter what suite to run. + client: { + args: [...config.client.args, '--grep', GREP], + }, + }); + + // The code coverage is only enabled when the flag is passed since it makes debugging the engine code harder. + if (COVERAGE) { + // Indicate to Karma to instrument the code to gather code coverage. + config.preprocessors[LWC_ENGINE] = ['coverage']; + + config.reporters.push('coverage'); + config.plugins.push('karma-coverage'); + + config.coverageReporter = { + dir: path.resolve(COVERAGE_DIR, TAGS.join('_')), + reporters: [{ type: 'html' }, { type: 'json' }], + }; + } +}; diff --git a/packages/integration-karma/scripts/karma-configs/local.js b/packages/integration-karma/scripts/karma-configs/local.js index 057e2454bf..7b7005ba9c 100644 --- a/packages/integration-karma/scripts/karma-configs/local.js +++ b/packages/integration-karma/scripts/karma-configs/local.js @@ -6,8 +6,8 @@ */ 'use strict'; - -const loadBaseConfig = require('./base'); +const { TEST_HYDRATION } = require('../shared/options'); +const loadBaseConfig = TEST_HYDRATION ? require('./hydration-base') : require('./base'); module.exports = (config) => { loadBaseConfig(config); diff --git a/packages/integration-karma/scripts/karma-configs/sauce.js b/packages/integration-karma/scripts/karma-configs/sauce.js index 19c552280c..4f06dfe41e 100644 --- a/packages/integration-karma/scripts/karma-configs/sauce.js +++ b/packages/integration-karma/scripts/karma-configs/sauce.js @@ -7,10 +7,10 @@ 'use strict'; -const localConfig = require('./base'); const { COMPAT, DISABLE_SYNTHETIC, + TEST_HYDRATION, TAGS, SAUCE_USERNAME, SAUCE_ACCESS_KEY, @@ -22,6 +22,8 @@ const { CIRCLE_SHA1, } = require('../shared/options'); +const localConfig = TEST_HYDRATION ? require('./hydration-base') : require('./base'); + const SAUCE_BROWSERS = [ // Standard browsers { @@ -30,6 +32,7 @@ const SAUCE_BROWSERS = [ version: 'latest', compat: false, nativeShadowCompatible: true, + test_hydration: true, }, { label: 'sl_firefox_latest', @@ -37,6 +40,7 @@ const SAUCE_BROWSERS = [ version: 'latest', compat: false, nativeShadowCompatible: true, + test_hydration: true, }, { label: 'sl_safari_latest', @@ -44,6 +48,7 @@ const SAUCE_BROWSERS = [ version: 'latest', compat: false, nativeShadowCompatible: true, + test_hydration: true, }, // Compat browsers @@ -126,6 +131,7 @@ function getSauceConfig() { function getMatchingBrowsers() { return SAUCE_BROWSERS.filter((browser) => { return ( + (!TEST_HYDRATION || browser.test_hydration === TEST_HYDRATION) && browser.compat === COMPAT && (!DISABLE_SYNTHETIC || browser.nativeShadowCompatible === DISABLE_SYNTHETIC) ); diff --git a/packages/integration-karma/scripts/karma-plugins/Watcher.js b/packages/integration-karma/scripts/karma-plugins/Watcher.js new file mode 100644 index 0000000000..293a743806 --- /dev/null +++ b/packages/integration-karma/scripts/karma-plugins/Watcher.js @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2018, 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 + */ +const chokidar = require('chokidar'); +const path = require('path'); + +module.exports = class Watcher { + constructor(config, emitter, logger) { + const { basePath } = config; + + this._suiteDependencyLookup = {}; + + this._watcher = chokidar.watch(basePath, { + ignoreInitial: true, + }); + + this._watcher.on('all', (_type, filename) => { + logger.info(`Change detected ${path.relative(basePath, filename)}`); + + for (const [input, dependencies] of Object.entries(this._suiteDependencyLookup)) { + if (dependencies.includes(filename)) { + // This is not a Karma public API, but it does the trick. This internal API has + // been pretty stable for a while now, so the probability it break is fairly + // low. + emitter._fileList.changeFile(input, true); + } + } + }); + } + + watchSuite(input, dependencies) { + this._suiteDependencyLookup[input] = dependencies; + } +}; diff --git a/packages/integration-karma/scripts/karma-plugins/hydration-tests.js b/packages/integration-karma/scripts/karma-plugins/hydration-tests.js new file mode 100644 index 0000000000..14c1e1a16a --- /dev/null +++ b/packages/integration-karma/scripts/karma-plugins/hydration-tests.js @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2018, 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 + */ +const path = require('path'); +const vm = require('vm'); +const { format } = require('util'); +const { rollup } = require('rollup'); +const lwcRollupPlugin = require('@lwc/rollup-plugin'); +const ssr = require('@lwc/engine-server'); +const Watcher = require('./Watcher'); + +const context = { + LWC: ssr, + moduleOutput: null, +}; + +ssr.setHooks({ + sanitizeHtmlContent(content) { + return content; + }, +}); + +const COMPONENT_UNDER_TEST = 'main'; + +const TEMPLATE = ` + (function (hydrateTest) { + const ssrRendered = %s; + // Component code, set as Main + %s; + // Test config, set as config + %s; + + describe(%s, () => { + it('test', () => { + return hydrateTest.runTest(ssrRendered, Main, config); + }) + }); + })(window.HydrateTest); +`; + +let cache; + +async function getCompiledModule(dirName) { + const bundle = await rollup({ + input: path.join(dirName, 'x', COMPONENT_UNDER_TEST, `${COMPONENT_UNDER_TEST}.js`), + plugins: [ + lwcRollupPlugin({ + modules: [ + { + dir: dirName, + }, + ], + experimentalDynamicComponent: { + loader: 'test-utils', + strict: true, + }, + }), + ], + cache, + + external: ['lwc', 'test-utils', '@test/loader'], // @todo: add ssr modules for test-utils and @test/loader + }); + + const { watchFiles } = bundle; + cache = bundle.cache; + + const { output } = await bundle.generate({ + format: 'iife', + name: 'Main', + globals: { + lwc: 'LWC', + 'test-utils': 'TestUtils', + }, + }); + + const { code } = output[0]; + + return { code, watchFiles }; +} + +function getSsrCode(moduleCode, testConfig) { + const script = new vm.Script( + ` + ${testConfig}; + config = config || {}; + ${moduleCode}; + moduleOutput = LWC.renderComponent('x-${COMPONENT_UNDER_TEST}', Main, config.props || {});` + ); + + vm.createContext(context); + script.runInContext(context); + + return context.moduleOutput; +} + +async function getTestModuleCode(input) { + const bundle = await rollup({ + input, + external: ['lwc', 'test-utils', '@test/loader'], + }); + + const { watchFiles } = bundle; + cache = bundle.cache; + + const { output } = await bundle.generate({ + format: 'iife', + globals: { + lwc: 'LWC', + 'test-utils': 'TestUtils', + }, + name: 'config', + }); + + const { code } = output[0]; + + return { code, watchFiles }; +} + +function createHCONFIG2JSPreprocessor(config, logger, emitter) { + const { basePath } = config; + const log = logger.create('preprocessor-lwc'); + const watcher = new Watcher(config, emitter, log); + + return async (_content, file, done) => { + const filePath = file.path; + const suiteDir = path.dirname(filePath); + // Wrap all the tests into a describe block with the file stricture name + const describeTitle = path.relative(basePath, suiteDir).split(path.sep).join(' '); + + try { + const { code: testCode, watchFiles: testWatchFiles } = await getTestModuleCode( + filePath + ); + const { code: componentDef, watchFiles: componentWatchFiles } = await getCompiledModule( + suiteDir + ); + + const ssrOutput = getSsrCode(componentDef, testCode); + + watcher.watchSuite(filePath, testWatchFiles.concat(componentWatchFiles)); + const newContent = format( + TEMPLATE, + JSON.stringify(ssrOutput), + componentDef, + testCode, + JSON.stringify(describeTitle) + ); + done(null, newContent); + } catch (error) { + const location = path.relative(basePath, filePath); + log.error('Error processing “%s”\n\n%s\n', location, error.stack || error.message); + + done(error, null); + } + }; +} + +createHCONFIG2JSPreprocessor.$inject = ['config', 'logger', 'emitter']; + +module.exports = { + 'preprocessor:hydration-tests': ['factory', createHCONFIG2JSPreprocessor], +}; diff --git a/packages/integration-karma/scripts/karma-plugins/lwc.js b/packages/integration-karma/scripts/karma-plugins/lwc.js index d2e7027798..03be800aa2 100644 --- a/packages/integration-karma/scripts/karma-plugins/lwc.js +++ b/packages/integration-karma/scripts/karma-plugins/lwc.js @@ -13,12 +13,12 @@ const path = require('path'); -const chokidar = require('chokidar'); const { rollup } = require('rollup'); const lwcRollupPlugin = require('@lwc/rollup-plugin'); const compatRollupPlugin = require('rollup-plugin-compat'); const { COMPAT } = require('../shared/options'); +const Watcher = require('./Watcher'); function createPreprocessor(config, emitter, logger) { const { basePath } = config; @@ -110,35 +110,6 @@ function createPreprocessor(config, emitter, logger) { }; } -class Watcher { - constructor(config, emitter, logger) { - const { basePath } = config; - - this._suiteDependencyLookup = {}; - - this._watcher = chokidar.watch(basePath, { - ignoreInitial: true, - }); - - this._watcher.on('all', (_type, filename) => { - logger.info(`Change detected ${path.relative(basePath, filename)}`); - - for (const [input, dependencies] of Object.entries(this._suiteDependencyLookup)) { - if (dependencies.includes(filename)) { - // This is not a Karma public API, but it does the trick. This internal API has - // been pretty stable for a while now, so the probability it break is fairly - // low. - emitter._fileList.changeFile(input, true); - } - } - }); - } - - watchSuite(input, dependencies) { - this._suiteDependencyLookup[input] = dependencies; - } -} - createPreprocessor.$inject = ['config', 'emitter', 'logger']; module.exports = { 'preprocessor:lwc': ['factory', createPreprocessor] }; diff --git a/packages/integration-karma/scripts/shared/options.js b/packages/integration-karma/scripts/shared/options.js index cdbd362113..fa4cf9251f 100644 --- a/packages/integration-karma/scripts/shared/options.js +++ b/packages/integration-karma/scripts/shared/options.js @@ -20,12 +20,14 @@ const TAGS = [ FORCE_NATIVE_SHADOW_MODE_FOR_TEST && 'force-native-shadow-mode', COMPAT && 'compat', ].filter(Boolean); +const TEST_HYDRATION = Boolean(process.env.TEST_HYDRATION); module.exports = { // Test configuration COMPAT, FORCE_NATIVE_SHADOW_MODE_FOR_TEST, SYNTHETIC_SHADOW_ENABLED: !DISABLE_SYNTHETIC, + TEST_HYDRATION, TAGS, GREP: process.env.GREP, COVERAGE: Boolean(process.env.COVERAGE), diff --git a/packages/integration-karma/test-hydration/component-lifecycle/connected-callback/index.spec.js b/packages/integration-karma/test-hydration/component-lifecycle/connected-callback/index.spec.js new file mode 100644 index 0000000000..d144f03346 --- /dev/null +++ b/packages/integration-karma/test-hydration/component-lifecycle/connected-callback/index.spec.js @@ -0,0 +1,15 @@ +export default { + snapshot(target) { + const p = target.shadowRoot.querySelector('p'); + return { + p, + text: p.firstChild, + }; + }, + test(target, snapshots) { + const p = target.shadowRoot.querySelector('p'); + expect(p).toBe(snapshots.p); + expect(p.firstChild).toBe(snapshots.text); + expect(p.textContent).toBe('connectedCallback:true'); + }, +}; diff --git a/packages/integration-karma/test-hydration/component-lifecycle/connected-callback/x/main/main.html b/packages/integration-karma/test-hydration/component-lifecycle/connected-callback/x/main/main.html new file mode 100644 index 0000000000..d958c7031d --- /dev/null +++ b/packages/integration-karma/test-hydration/component-lifecycle/connected-callback/x/main/main.html @@ -0,0 +1,3 @@ + diff --git a/packages/integration-karma/test-hydration/component-lifecycle/connected-callback/x/main/main.js b/packages/integration-karma/test-hydration/component-lifecycle/connected-callback/x/main/main.js new file mode 100644 index 0000000000..536029b45c --- /dev/null +++ b/packages/integration-karma/test-hydration/component-lifecycle/connected-callback/x/main/main.js @@ -0,0 +1,8 @@ +import { LightningElement } from 'lwc'; + +export default class Main extends LightningElement { + called = false; + connectedCallback() { + this.called = true; + } +} diff --git a/packages/integration-karma/test-hydration/component-lifecycle/disconnected-callback/index.spec.js b/packages/integration-karma/test-hydration/component-lifecycle/disconnected-callback/index.spec.js new file mode 100644 index 0000000000..49a9044c3f --- /dev/null +++ b/packages/integration-karma/test-hydration/component-lifecycle/disconnected-callback/index.spec.js @@ -0,0 +1,28 @@ +let disconnectedCalled = false; + +// NOTE: Disconnected callback is not triggered by Node.remove, see: https://github.com/salesforce/lwc/issues/1102 +// That's why we trick it by removing a component via the diffing algo. +export default { + props: { + showFoo: true, + disconnectedCb: () => { + disconnectedCalled = true; + }, + }, + snapshot(target) { + return { + xFoo: target.shadowRoot.querySelector('x-foo'), + }; + }, + test(target, snapshots) { + const xFoo = target.shadowRoot.querySelector('x-foo'); + expect(xFoo).not.toBe(null); + expect(xFoo).toBe(snapshots.xFoo); + + target.showFoo = false; + + return Promise.resolve().then(() => { + expect(disconnectedCalled).toBe(true); + }); + }, +}; diff --git a/packages/integration-karma/test-hydration/component-lifecycle/disconnected-callback/x/foo/foo.html b/packages/integration-karma/test-hydration/component-lifecycle/disconnected-callback/x/foo/foo.html new file mode 100644 index 0000000000..3323d4e315 --- /dev/null +++ b/packages/integration-karma/test-hydration/component-lifecycle/disconnected-callback/x/foo/foo.html @@ -0,0 +1,3 @@ + diff --git a/packages/integration-karma/test-hydration/component-lifecycle/disconnected-callback/x/foo/foo.js b/packages/integration-karma/test-hydration/component-lifecycle/disconnected-callback/x/foo/foo.js new file mode 100644 index 0000000000..5dda3d0d96 --- /dev/null +++ b/packages/integration-karma/test-hydration/component-lifecycle/disconnected-callback/x/foo/foo.js @@ -0,0 +1,9 @@ +import { LightningElement, api } from 'lwc'; + +export default class Foo extends LightningElement { + @api disconnectedCb; + + disconnectedCallback() { + this.disconnectedCb.call(null); + } +} diff --git a/packages/integration-karma/test-hydration/component-lifecycle/disconnected-callback/x/main/main.html b/packages/integration-karma/test-hydration/component-lifecycle/disconnected-callback/x/main/main.html new file mode 100644 index 0000000000..4f110f35a5 --- /dev/null +++ b/packages/integration-karma/test-hydration/component-lifecycle/disconnected-callback/x/main/main.html @@ -0,0 +1,6 @@ + diff --git a/packages/integration-karma/test-hydration/component-lifecycle/disconnected-callback/x/main/main.js b/packages/integration-karma/test-hydration/component-lifecycle/disconnected-callback/x/main/main.js new file mode 100644 index 0000000000..c539fe590a --- /dev/null +++ b/packages/integration-karma/test-hydration/component-lifecycle/disconnected-callback/x/main/main.js @@ -0,0 +1,10 @@ +import { LightningElement, api } from 'lwc'; + +export default class Main extends LightningElement { + @api disconnectedCb; + @api showFoo; + + disconnectedCallback() { + this.disconnectedCb.call(null); + } +} diff --git a/packages/integration-karma/test-hydration/component-lifecycle/render-method/index.spec.js b/packages/integration-karma/test-hydration/component-lifecycle/render-method/index.spec.js new file mode 100644 index 0000000000..feae6ccffb --- /dev/null +++ b/packages/integration-karma/test-hydration/component-lifecycle/render-method/index.spec.js @@ -0,0 +1,24 @@ +export default { + props: { + useTplA: true, + }, + snapshot(target) { + const p = target.shadowRoot.querySelector('p'); + return { + p, + text: p.firstChild, + }; + }, + test(target, snapshots) { + const p = target.shadowRoot.querySelector('p'); + expect(p).toBe(snapshots.p); + expect(p.firstChild).toBe(snapshots.text); + expect(p.textContent).toBe('template A'); + + target.useTplA = false; + + return Promise.resolve().then(() => { + expect(target.shadowRoot.querySelector('p').textContent).toBe('template B'); + }); + }, +}; diff --git a/packages/integration-karma/test-hydration/component-lifecycle/render-method/x/main/a.html b/packages/integration-karma/test-hydration/component-lifecycle/render-method/x/main/a.html new file mode 100644 index 0000000000..1aa0588b62 --- /dev/null +++ b/packages/integration-karma/test-hydration/component-lifecycle/render-method/x/main/a.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/packages/integration-karma/test-hydration/component-lifecycle/render-method/x/main/b.html b/packages/integration-karma/test-hydration/component-lifecycle/render-method/x/main/b.html new file mode 100644 index 0000000000..012c7a045a --- /dev/null +++ b/packages/integration-karma/test-hydration/component-lifecycle/render-method/x/main/b.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/packages/integration-karma/test-hydration/component-lifecycle/render-method/x/main/main.js b/packages/integration-karma/test-hydration/component-lifecycle/render-method/x/main/main.js new file mode 100644 index 0000000000..357356aab6 --- /dev/null +++ b/packages/integration-karma/test-hydration/component-lifecycle/render-method/x/main/main.js @@ -0,0 +1,11 @@ +import { LightningElement, api } from 'lwc'; +import tplA from './a.html'; +import tplB from './b.html'; + +export default class Main extends LightningElement { + @api useTplA; + + render() { + return this.useTplA ? tplA : tplB; + } +} diff --git a/packages/integration-karma/test-hydration/component-lifecycle/rendered-callback/index.spec.js b/packages/integration-karma/test-hydration/component-lifecycle/rendered-callback/index.spec.js new file mode 100644 index 0000000000..4754206100 --- /dev/null +++ b/packages/integration-karma/test-hydration/component-lifecycle/rendered-callback/index.spec.js @@ -0,0 +1,19 @@ +export default { + snapshot(target) { + const p = target.shadowRoot.querySelector('p'); + return { + p, + text: p.firstChild, + }; + }, + test(target, snapshots) { + const p = target.shadowRoot.querySelector('p'); + expect(p).toBe(snapshots.p); + expect(p.firstChild).toBe(snapshots.text); + expect(p.textContent).toBe('renderedCallback:false'); + + return Promise.resolve().then(() => { + expect(p.textContent).toBe('renderedCallback:true'); + }); + }, +}; diff --git a/packages/integration-karma/test-hydration/component-lifecycle/rendered-callback/x/main/main.html b/packages/integration-karma/test-hydration/component-lifecycle/rendered-callback/x/main/main.html new file mode 100644 index 0000000000..caeecb703f --- /dev/null +++ b/packages/integration-karma/test-hydration/component-lifecycle/rendered-callback/x/main/main.html @@ -0,0 +1,3 @@ + diff --git a/packages/integration-karma/test-hydration/component-lifecycle/rendered-callback/x/main/main.js b/packages/integration-karma/test-hydration/component-lifecycle/rendered-callback/x/main/main.js new file mode 100644 index 0000000000..cd483b8092 --- /dev/null +++ b/packages/integration-karma/test-hydration/component-lifecycle/rendered-callback/x/main/main.js @@ -0,0 +1,8 @@ +import { LightningElement } from 'lwc'; + +export default class Main extends LightningElement { + called = false; + renderedCallback() { + this.called = true; + } +} diff --git a/packages/integration-karma/test-hydration/directives/comments/index.spec.js b/packages/integration-karma/test-hydration/directives/comments/index.spec.js new file mode 100644 index 0000000000..9feeac3475 --- /dev/null +++ b/packages/integration-karma/test-hydration/directives/comments/index.spec.js @@ -0,0 +1,26 @@ +export default { + props: { + control: true, + }, + snapshot(target) { + const [firstComment, p] = target.shadowRoot.childNodes; + const [secondComment, text] = p.childNodes; + return { + firstComment, + p, + secondComment, + text, + }; + }, + test(target, snapshots) { + const [firstComment, p] = target.shadowRoot.childNodes; + const [secondComment, text] = p.childNodes; + + expect(firstComment).toBe(snapshots.firstComment); + expect(firstComment.nodeValue).toBe('first comment'); + expect(p).toBe(snapshots.p); + expect(secondComment).toBe(snapshots.secondComment); + expect(secondComment.nodeValue).toBe('comment inside element'); + expect(text).toBe(snapshots.text); + }, +}; diff --git a/packages/integration-karma/test-hydration/directives/comments/x/main/main.html b/packages/integration-karma/test-hydration/directives/comments/x/main/main.html new file mode 100644 index 0000000000..aca22f191d --- /dev/null +++ b/packages/integration-karma/test-hydration/directives/comments/x/main/main.html @@ -0,0 +1,7 @@ + diff --git a/packages/integration-karma/test-hydration/directives/comments/x/main/main.js b/packages/integration-karma/test-hydration/directives/comments/x/main/main.js new file mode 100644 index 0000000000..0eaadfe543 --- /dev/null +++ b/packages/integration-karma/test-hydration/directives/comments/x/main/main.js @@ -0,0 +1,5 @@ +import { LightningElement, api } from 'lwc'; + +export default class Main extends LightningElement { + @api control; +} diff --git a/packages/integration-karma/test-hydration/directives/dom-manual/index.spec.js b/packages/integration-karma/test-hydration/directives/dom-manual/index.spec.js new file mode 100644 index 0000000000..41ceea382e --- /dev/null +++ b/packages/integration-karma/test-hydration/directives/dom-manual/index.spec.js @@ -0,0 +1,13 @@ +export default { + snapshot(target) { + return { + div: target.shadowRoot.querySelector('div'), + }; + }, + test(target, snapshots) { + const div = target.shadowRoot.querySelector('div'); + + expect(div).toBe(snapshots.div); + expect(div.innerHTML).toBe('

test

'); + }, +}; diff --git a/packages/integration-karma/test-hydration/directives/dom-manual/x/main/main.html b/packages/integration-karma/test-hydration/directives/dom-manual/x/main/main.html new file mode 100644 index 0000000000..afd9355489 --- /dev/null +++ b/packages/integration-karma/test-hydration/directives/dom-manual/x/main/main.html @@ -0,0 +1,3 @@ + diff --git a/packages/integration-karma/test-hydration/directives/dom-manual/x/main/main.js b/packages/integration-karma/test-hydration/directives/dom-manual/x/main/main.js new file mode 100644 index 0000000000..ec1b237c50 --- /dev/null +++ b/packages/integration-karma/test-hydration/directives/dom-manual/x/main/main.js @@ -0,0 +1,7 @@ +import { LightningElement } from 'lwc'; + +export default class Main extends LightningElement { + renderedCallback() { + this.template.querySelector('div').innerHTML = '

test

'; + } +} diff --git a/packages/integration-karma/test-hydration/directives/for-each/index.spec.js b/packages/integration-karma/test-hydration/directives/for-each/index.spec.js new file mode 100644 index 0000000000..6bac7c837f --- /dev/null +++ b/packages/integration-karma/test-hydration/directives/for-each/index.spec.js @@ -0,0 +1,31 @@ +export default { + props: { + colors: ['red', 'yellow', 'blue'], + }, + snapshot(target) { + return { + ul: target.shadowRoot.querySelector('ul'), + colors: target.shadowRoot.querySelectorAll('li'), + }; + }, + test(target, snapshots) { + const ul = target.shadowRoot.querySelector('ul'); + let colors = ul.querySelectorAll('li'); + expect(ul).toBe(snapshots.ul); + expect(colors[0]).toBe(snapshots.colors[0]); + expect(colors[0].textContent).toBe('red'); + expect(colors[1]).toBe(snapshots.colors[1]); + expect(colors[1].textContent).toBe('yellow'); + expect(colors[2]).toBe(snapshots.colors[2]); + expect(colors[2].textContent).toBe('blue'); + + target.colors = ['orange', 'green', 'violet']; + + return Promise.resolve().then(() => { + colors = ul.querySelectorAll('li'); + expect(colors[0].textContent).toBe('orange'); + expect(colors[1].textContent).toBe('green'); + expect(colors[2].textContent).toBe('violet'); + }); + }, +}; diff --git a/packages/integration-karma/test-hydration/directives/for-each/x/main/main.html b/packages/integration-karma/test-hydration/directives/for-each/x/main/main.html new file mode 100644 index 0000000000..f478978aa9 --- /dev/null +++ b/packages/integration-karma/test-hydration/directives/for-each/x/main/main.html @@ -0,0 +1,7 @@ + diff --git a/packages/integration-karma/test-hydration/directives/for-each/x/main/main.js b/packages/integration-karma/test-hydration/directives/for-each/x/main/main.js new file mode 100644 index 0000000000..88c9d7ed9d --- /dev/null +++ b/packages/integration-karma/test-hydration/directives/for-each/x/main/main.js @@ -0,0 +1,5 @@ +import { LightningElement, api } from 'lwc'; + +export default class Main extends LightningElement { + @api colors; +} diff --git a/packages/integration-karma/test-hydration/directives/if-false/index.spec.js b/packages/integration-karma/test-hydration/directives/if-false/index.spec.js new file mode 100644 index 0000000000..0a7930b796 --- /dev/null +++ b/packages/integration-karma/test-hydration/directives/if-false/index.spec.js @@ -0,0 +1,19 @@ +export default { + props: { + control: false, + }, + snapshot(target) { + return { + p: target.shadowRoot.querySelector('p'), + }; + }, + test(target, snapshots) { + expect(target.shadowRoot.querySelector('p')).toBe(snapshots.p); + + target.control = true; + + return Promise.resolve().then(() => { + expect(target.shadowRoot.querySelector('p')).toBeNull(); + }); + }, +}; diff --git a/packages/integration-karma/test-hydration/directives/if-false/x/main/main.html b/packages/integration-karma/test-hydration/directives/if-false/x/main/main.html new file mode 100644 index 0000000000..6cdc0f58a6 --- /dev/null +++ b/packages/integration-karma/test-hydration/directives/if-false/x/main/main.html @@ -0,0 +1,5 @@ + diff --git a/packages/integration-karma/test-hydration/directives/if-false/x/main/main.js b/packages/integration-karma/test-hydration/directives/if-false/x/main/main.js new file mode 100644 index 0000000000..0eaadfe543 --- /dev/null +++ b/packages/integration-karma/test-hydration/directives/if-false/x/main/main.js @@ -0,0 +1,5 @@ +import { LightningElement, api } from 'lwc'; + +export default class Main extends LightningElement { + @api control; +} diff --git a/packages/integration-karma/test-hydration/directives/if-true/index.spec.js b/packages/integration-karma/test-hydration/directives/if-true/index.spec.js new file mode 100644 index 0000000000..196e52d647 --- /dev/null +++ b/packages/integration-karma/test-hydration/directives/if-true/index.spec.js @@ -0,0 +1,19 @@ +export default { + props: { + control: true, + }, + snapshot(target) { + return { + p: target.shadowRoot.querySelector('p'), + }; + }, + test(target, snapshots) { + expect(target.shadowRoot.querySelector('p')).toBe(snapshots.p); + + target.control = false; + + return Promise.resolve().then(() => { + expect(target.shadowRoot.querySelector('p')).toBeNull(); + }); + }, +}; diff --git a/packages/integration-karma/test-hydration/directives/if-true/x/main/main.html b/packages/integration-karma/test-hydration/directives/if-true/x/main/main.html new file mode 100644 index 0000000000..c5d80d15d4 --- /dev/null +++ b/packages/integration-karma/test-hydration/directives/if-true/x/main/main.html @@ -0,0 +1,5 @@ + diff --git a/packages/integration-karma/test-hydration/directives/if-true/x/main/main.js b/packages/integration-karma/test-hydration/directives/if-true/x/main/main.js new file mode 100644 index 0000000000..0eaadfe543 --- /dev/null +++ b/packages/integration-karma/test-hydration/directives/if-true/x/main/main.js @@ -0,0 +1,5 @@ +import { LightningElement, api } from 'lwc'; + +export default class Main extends LightningElement { + @api control; +} diff --git a/packages/integration-karma/test-hydration/directives/iterator/index.spec.js b/packages/integration-karma/test-hydration/directives/iterator/index.spec.js new file mode 100644 index 0000000000..6bac7c837f --- /dev/null +++ b/packages/integration-karma/test-hydration/directives/iterator/index.spec.js @@ -0,0 +1,31 @@ +export default { + props: { + colors: ['red', 'yellow', 'blue'], + }, + snapshot(target) { + return { + ul: target.shadowRoot.querySelector('ul'), + colors: target.shadowRoot.querySelectorAll('li'), + }; + }, + test(target, snapshots) { + const ul = target.shadowRoot.querySelector('ul'); + let colors = ul.querySelectorAll('li'); + expect(ul).toBe(snapshots.ul); + expect(colors[0]).toBe(snapshots.colors[0]); + expect(colors[0].textContent).toBe('red'); + expect(colors[1]).toBe(snapshots.colors[1]); + expect(colors[1].textContent).toBe('yellow'); + expect(colors[2]).toBe(snapshots.colors[2]); + expect(colors[2].textContent).toBe('blue'); + + target.colors = ['orange', 'green', 'violet']; + + return Promise.resolve().then(() => { + colors = ul.querySelectorAll('li'); + expect(colors[0].textContent).toBe('orange'); + expect(colors[1].textContent).toBe('green'); + expect(colors[2].textContent).toBe('violet'); + }); + }, +}; diff --git a/packages/integration-karma/test-hydration/directives/iterator/x/main/main.html b/packages/integration-karma/test-hydration/directives/iterator/x/main/main.html new file mode 100644 index 0000000000..8e5140f566 --- /dev/null +++ b/packages/integration-karma/test-hydration/directives/iterator/x/main/main.html @@ -0,0 +1,7 @@ + diff --git a/packages/integration-karma/test-hydration/directives/iterator/x/main/main.js b/packages/integration-karma/test-hydration/directives/iterator/x/main/main.js new file mode 100644 index 0000000000..88c9d7ed9d --- /dev/null +++ b/packages/integration-karma/test-hydration/directives/iterator/x/main/main.js @@ -0,0 +1,5 @@ +import { LightningElement, api } from 'lwc'; + +export default class Main extends LightningElement { + @api colors; +} diff --git a/packages/integration-karma/test-hydration/directives/lwc-dynamic/index.spec.js b/packages/integration-karma/test-hydration/directives/lwc-dynamic/index.spec.js new file mode 100644 index 0000000000..9ecce85f91 --- /dev/null +++ b/packages/integration-karma/test-hydration/directives/lwc-dynamic/index.spec.js @@ -0,0 +1,22 @@ +export default { + props: { + label: 'dynamic', + }, + snapshot(target) { + const cmp = target.shadowRoot.querySelector('x-dynamic-cmp'); + const p = cmp.shadowRoot.querySelector('p'); + + return { + cmp, + p, + }; + }, + test(target, snapshots) { + const cmp = target.shadowRoot.querySelector('x-dynamic-cmp'); + const p = cmp.shadowRoot.querySelector('p'); + + expect(cmp).toBe(snapshots.cmp); + expect(p).toBe(snapshots.p); + expect(p.textContent).toBe('dynamic'); + }, +}; diff --git a/packages/integration-karma/test-hydration/directives/lwc-dynamic/x/child/child.html b/packages/integration-karma/test-hydration/directives/lwc-dynamic/x/child/child.html new file mode 100644 index 0000000000..260d58602d --- /dev/null +++ b/packages/integration-karma/test-hydration/directives/lwc-dynamic/x/child/child.html @@ -0,0 +1,3 @@ + diff --git a/packages/integration-karma/test-hydration/directives/lwc-dynamic/x/child/child.js b/packages/integration-karma/test-hydration/directives/lwc-dynamic/x/child/child.js new file mode 100644 index 0000000000..e250b53c38 --- /dev/null +++ b/packages/integration-karma/test-hydration/directives/lwc-dynamic/x/child/child.js @@ -0,0 +1,5 @@ +import { LightningElement, api } from 'lwc'; + +export default class Child extends LightningElement { + @api label; +} diff --git a/packages/integration-karma/test-hydration/directives/lwc-dynamic/x/main/main.html b/packages/integration-karma/test-hydration/directives/lwc-dynamic/x/main/main.html new file mode 100644 index 0000000000..100385d78d --- /dev/null +++ b/packages/integration-karma/test-hydration/directives/lwc-dynamic/x/main/main.html @@ -0,0 +1,3 @@ + diff --git a/packages/integration-karma/test-hydration/directives/lwc-dynamic/x/main/main.js b/packages/integration-karma/test-hydration/directives/lwc-dynamic/x/main/main.js new file mode 100644 index 0000000000..d64d3311e8 --- /dev/null +++ b/packages/integration-karma/test-hydration/directives/lwc-dynamic/x/main/main.js @@ -0,0 +1,7 @@ +import { LightningElement, api } from 'lwc'; +import Child from 'x/child'; + +export default class Main extends LightningElement { + @api label; + Ctor = Child; +} diff --git a/packages/integration-karma/test-hydration/directives/lwc-inner-html/index.spec.js b/packages/integration-karma/test-hydration/directives/lwc-inner-html/index.spec.js new file mode 100644 index 0000000000..3727317561 --- /dev/null +++ b/packages/integration-karma/test-hydration/directives/lwc-inner-html/index.spec.js @@ -0,0 +1,22 @@ +export default { + props: { + content: '

test-content

', + }, + snapshot(target) { + const div = target.shadowRoot.querySelector('div'); + const p = div.querySelector('p'); + return { + div, + p, + text: p.textContent, + }; + }, + test(target, snapshot) { + const div = target.shadowRoot.querySelector('div'); + const p = div.querySelector('p'); + + expect(div).toBe(snapshot.div); + expect(p).toBe(snapshot.p); + expect(p.textContent).toBe(snapshot.text); + }, +}; diff --git a/packages/integration-karma/test-hydration/directives/lwc-inner-html/x/main/main.html b/packages/integration-karma/test-hydration/directives/lwc-inner-html/x/main/main.html new file mode 100644 index 0000000000..66a8e2c720 --- /dev/null +++ b/packages/integration-karma/test-hydration/directives/lwc-inner-html/x/main/main.html @@ -0,0 +1,3 @@ + diff --git a/packages/integration-karma/test-hydration/directives/lwc-inner-html/x/main/main.js b/packages/integration-karma/test-hydration/directives/lwc-inner-html/x/main/main.js new file mode 100644 index 0000000000..8066dd4ab7 --- /dev/null +++ b/packages/integration-karma/test-hydration/directives/lwc-inner-html/x/main/main.js @@ -0,0 +1,5 @@ +import { LightningElement, api } from 'lwc'; + +export default class Main extends LightningElement { + @api content; +} diff --git a/packages/integration-karma/test-hydration/mismatches/attrs-compatibility/index.spec.js b/packages/integration-karma/test-hydration/mismatches/attrs-compatibility/index.spec.js new file mode 100644 index 0000000000..5f068fa9da --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/attrs-compatibility/index.spec.js @@ -0,0 +1,30 @@ +export default { + props: { + ssr: true, + }, + clientProps: { + ssr: false, + }, + snapshot(target) { + const p = target.shadowRoot.querySelector('p'); + return { + p, + }; + }, + test(target, snapshots, consoleCalls) { + const p = target.shadowRoot.querySelector('p'); + expect(p).not.toBe(snapshots.p); + expect(p.getAttribute('title')).toBe('client-title'); + expect(p.getAttribute('data-same')).toBe('same-value'); + expect(p.getAttribute('data-another-diff')).toBe('client-val'); + + expect(consoleCalls.error).toHaveSize(3); + expect(consoleCalls.error[0][0].message).toContain( + 'Mismatch hydrating element

: attribute "title" has different values, expected "client-title" but found "ssr-title"' + ); + expect(consoleCalls.error[1][0].message).toContain( + 'Mismatch hydrating element

: attribute "data-another-diff" has different values, expected "client-val" but found "ssr-val"' + ); + expect(consoleCalls.error[2][0]).toContain('Recovering from error while hydrating'); + }, +}; diff --git a/packages/integration-karma/test-hydration/mismatches/attrs-compatibility/x/main/main.html b/packages/integration-karma/test-hydration/mismatches/attrs-compatibility/x/main/main.html new file mode 100644 index 0000000000..72e815ac86 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/attrs-compatibility/x/main/main.html @@ -0,0 +1,18 @@ + diff --git a/packages/integration-karma/test-hydration/mismatches/attrs-compatibility/x/main/main.js b/packages/integration-karma/test-hydration/mismatches/attrs-compatibility/x/main/main.js new file mode 100644 index 0000000000..8d248b4077 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/attrs-compatibility/x/main/main.js @@ -0,0 +1,5 @@ +import { LightningElement, api } from 'lwc'; + +export default class Main extends LightningElement { + @api ssr; +} diff --git a/packages/integration-karma/test-hydration/mismatches/class-attr/dynamic-different/index.spec.js b/packages/integration-karma/test-hydration/mismatches/class-attr/dynamic-different/index.spec.js new file mode 100644 index 0000000000..76417af48a --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/class-attr/dynamic-different/index.spec.js @@ -0,0 +1,27 @@ +export default { + props: { + classes: 'c1 c2 c3', + }, + clientProps: { + classes: 'c2 c3 c4', + }, + snapshot(target) { + const p = target.shadowRoot.querySelector('p'); + return { + p, + classes: p.className, + }; + }, + test(target, snapshots, consoleCalls) { + const p = target.shadowRoot.querySelector('p'); + + expect(p).not.toBe(snapshots.p); + expect(p.className).not.toBe(snapshots.classes); + + expect(consoleCalls.error).toHaveSize(2); + expect(consoleCalls.error[0][0].message).toContain( + 'Mismatch hydrating element

: attribute "class" has different values, expected "c2 c3 c4" but found "c1 c2 c3"' + ); + expect(consoleCalls.error[1][0]).toContain('Recovering from error while hydrating'); + }, +}; diff --git a/packages/integration-karma/test-hydration/mismatches/class-attr/dynamic-different/x/main/main.html b/packages/integration-karma/test-hydration/mismatches/class-attr/dynamic-different/x/main/main.html new file mode 100644 index 0000000000..840c1025f9 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/class-attr/dynamic-different/x/main/main.html @@ -0,0 +1,3 @@ + diff --git a/packages/integration-karma/test-hydration/mismatches/class-attr/dynamic-different/x/main/main.js b/packages/integration-karma/test-hydration/mismatches/class-attr/dynamic-different/x/main/main.js new file mode 100644 index 0000000000..a9d7d6f286 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/class-attr/dynamic-different/x/main/main.js @@ -0,0 +1,5 @@ +import { LightningElement, api } from 'lwc'; + +export default class Main extends LightningElement { + @api classes; +} diff --git a/packages/integration-karma/test-hydration/mismatches/class-attr/dynamic-same-different-order-throws/index.spec.js b/packages/integration-karma/test-hydration/mismatches/class-attr/dynamic-same-different-order-throws/index.spec.js new file mode 100644 index 0000000000..367235aea4 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/class-attr/dynamic-same-different-order-throws/index.spec.js @@ -0,0 +1,27 @@ +export default { + props: { + classes: 'c1 c2 c3', + }, + clientProps: { + classes: 'c3 c2 c1', + }, + snapshot(target) { + const p = target.shadowRoot.querySelector('p'); + return { + p, + classes: p.className, + }; + }, + test(target, snapshots, consoleCalls) { + const p = target.shadowRoot.querySelector('p'); + + expect(p).not.toBe(snapshots.p); + expect(p.className).not.toBe(snapshots.classes); + + expect(consoleCalls.error).toHaveSize(2); + expect(consoleCalls.error[0][0].message).toContain( + 'Mismatch hydrating element

: attribute "class" has different values, expected "c3 c2 c1" but found "c1 c2 c3"' + ); + expect(consoleCalls.error[1][0]).toContain('Recovering from error while hydrating'); + }, +}; diff --git a/packages/integration-karma/test-hydration/mismatches/class-attr/dynamic-same-different-order-throws/x/main/main.html b/packages/integration-karma/test-hydration/mismatches/class-attr/dynamic-same-different-order-throws/x/main/main.html new file mode 100644 index 0000000000..840c1025f9 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/class-attr/dynamic-same-different-order-throws/x/main/main.html @@ -0,0 +1,3 @@ + diff --git a/packages/integration-karma/test-hydration/mismatches/class-attr/dynamic-same-different-order-throws/x/main/main.js b/packages/integration-karma/test-hydration/mismatches/class-attr/dynamic-same-different-order-throws/x/main/main.js new file mode 100644 index 0000000000..a9d7d6f286 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/class-attr/dynamic-same-different-order-throws/x/main/main.js @@ -0,0 +1,5 @@ +import { LightningElement, api } from 'lwc'; + +export default class Main extends LightningElement { + @api classes; +} diff --git a/packages/integration-karma/test-hydration/mismatches/class-attr/dynamic-same/index.spec.js b/packages/integration-karma/test-hydration/mismatches/class-attr/dynamic-same/index.spec.js new file mode 100644 index 0000000000..2b318e9d58 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/class-attr/dynamic-same/index.spec.js @@ -0,0 +1,18 @@ +export default { + props: { + classes: 'c1 c2 c3', + }, + snapshot(target) { + const p = target.shadowRoot.querySelector('p'); + return { + p, + classes: p.className, + }; + }, + test(target, snapshots) { + const p = target.shadowRoot.querySelector('p'); + + expect(p).toBe(snapshots.p); + expect(p.className).toBe(snapshots.classes); + }, +}; diff --git a/packages/integration-karma/test-hydration/mismatches/class-attr/dynamic-same/x/main/main.html b/packages/integration-karma/test-hydration/mismatches/class-attr/dynamic-same/x/main/main.html new file mode 100644 index 0000000000..840c1025f9 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/class-attr/dynamic-same/x/main/main.html @@ -0,0 +1,3 @@ + diff --git a/packages/integration-karma/test-hydration/mismatches/class-attr/dynamic-same/x/main/main.js b/packages/integration-karma/test-hydration/mismatches/class-attr/dynamic-same/x/main/main.js new file mode 100644 index 0000000000..a9d7d6f286 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/class-attr/dynamic-same/x/main/main.js @@ -0,0 +1,5 @@ +import { LightningElement, api } from 'lwc'; + +export default class Main extends LightningElement { + @api classes; +} diff --git a/packages/integration-karma/test-hydration/mismatches/class-attr/static-extra-class-from-client/index.spec.js b/packages/integration-karma/test-hydration/mismatches/class-attr/static-extra-class-from-client/index.spec.js new file mode 100644 index 0000000000..37c3dfd48e --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/class-attr/static-extra-class-from-client/index.spec.js @@ -0,0 +1,28 @@ +export default { + props: { + ssr: true, + }, + clientProps: { + ssr: false, + }, + snapshot(target) { + const p = target.shadowRoot.querySelector('p'); + return { + p, + classes: p.className, + }; + }, + test(target, snapshots, consoleCalls) { + const p = target.shadowRoot.querySelector('p'); + + expect(p).not.toBe(snapshots.p); + expect(p.className).not.toBe(snapshots.classes); + expect(p.className).toBe('c1 c2 c3'); + + expect(consoleCalls.error).toHaveSize(2); + expect(consoleCalls.error[0][0].message).toContain( + 'Mismatch hydrating element

: attribute "class" has different values, expected "c1 c2 c3" but found "c1 c3"' + ); + expect(consoleCalls.error[1][0]).toContain('Recovering from error while hydrating'); + }, +}; diff --git a/packages/integration-karma/test-hydration/mismatches/class-attr/static-extra-class-from-client/x/main/main.html b/packages/integration-karma/test-hydration/mismatches/class-attr/static-extra-class-from-client/x/main/main.html new file mode 100644 index 0000000000..80f48b8b0f --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/class-attr/static-extra-class-from-client/x/main/main.html @@ -0,0 +1,8 @@ + diff --git a/packages/integration-karma/test-hydration/mismatches/class-attr/static-extra-class-from-client/x/main/main.js b/packages/integration-karma/test-hydration/mismatches/class-attr/static-extra-class-from-client/x/main/main.js new file mode 100644 index 0000000000..8d248b4077 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/class-attr/static-extra-class-from-client/x/main/main.js @@ -0,0 +1,5 @@ +import { LightningElement, api } from 'lwc'; + +export default class Main extends LightningElement { + @api ssr; +} diff --git a/packages/integration-karma/test-hydration/mismatches/class-attr/static-extra-class-from-server/index.spec.js b/packages/integration-karma/test-hydration/mismatches/class-attr/static-extra-class-from-server/index.spec.js new file mode 100644 index 0000000000..92fa7f8dde --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/class-attr/static-extra-class-from-server/index.spec.js @@ -0,0 +1,28 @@ +export default { + props: { + ssr: true, + }, + clientProps: { + ssr: false, + }, + snapshot(target) { + const p = target.shadowRoot.querySelector('p'); + return { + p, + classes: p.className, + }; + }, + test(target, snapshots, consoleCalls) { + const p = target.shadowRoot.querySelector('p'); + + expect(p).not.toBe(snapshots.p); + expect(p.className).not.toBe(snapshots.classes); + expect(p.className).toBe('c1 c3'); + + expect(consoleCalls.error).toHaveSize(2); + expect(consoleCalls.error[0][0].message).toContain( + 'Mismatch hydrating element

: attribute "class" has different values, expected "c1 c3" but found "c1 c2 c3"' + ); + expect(consoleCalls.error[1][0]).toContain('Recovering from error while hydrating'); + }, +}; diff --git a/packages/integration-karma/test-hydration/mismatches/class-attr/static-extra-class-from-server/x/main/main.html b/packages/integration-karma/test-hydration/mismatches/class-attr/static-extra-class-from-server/x/main/main.html new file mode 100644 index 0000000000..b7da6683e3 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/class-attr/static-extra-class-from-server/x/main/main.html @@ -0,0 +1,8 @@ + diff --git a/packages/integration-karma/test-hydration/mismatches/class-attr/static-extra-class-from-server/x/main/main.js b/packages/integration-karma/test-hydration/mismatches/class-attr/static-extra-class-from-server/x/main/main.js new file mode 100644 index 0000000000..8d248b4077 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/class-attr/static-extra-class-from-server/x/main/main.js @@ -0,0 +1,5 @@ +import { LightningElement, api } from 'lwc'; + +export default class Main extends LightningElement { + @api ssr; +} diff --git a/packages/integration-karma/test-hydration/mismatches/class-attr/static-same-different-order/index.spec.js b/packages/integration-karma/test-hydration/mismatches/class-attr/static-same-different-order/index.spec.js new file mode 100644 index 0000000000..bd0771c845 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/class-attr/static-same-different-order/index.spec.js @@ -0,0 +1,21 @@ +export default { + props: { + ssr: true, + }, + clientProps: { + ssr: false, + }, + snapshot(target) { + const p = target.shadowRoot.querySelector('p'); + return { + p, + classes: p.className, + }; + }, + test(target, snapshots) { + const p = target.shadowRoot.querySelector('p'); + + expect(p).toBe(snapshots.p); + expect(p.className).toBe(snapshots.classes); + }, +}; diff --git a/packages/integration-karma/test-hydration/mismatches/class-attr/static-same-different-order/x/main/main.html b/packages/integration-karma/test-hydration/mismatches/class-attr/static-same-different-order/x/main/main.html new file mode 100644 index 0000000000..915b6893b3 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/class-attr/static-same-different-order/x/main/main.html @@ -0,0 +1,8 @@ + diff --git a/packages/integration-karma/test-hydration/mismatches/class-attr/static-same-different-order/x/main/main.js b/packages/integration-karma/test-hydration/mismatches/class-attr/static-same-different-order/x/main/main.js new file mode 100644 index 0000000000..8d248b4077 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/class-attr/static-same-different-order/x/main/main.js @@ -0,0 +1,5 @@ +import { LightningElement, api } from 'lwc'; + +export default class Main extends LightningElement { + @api ssr; +} diff --git a/packages/integration-karma/test-hydration/mismatches/comment-instead-of-text/index.spec.js b/packages/integration-karma/test-hydration/mismatches/comment-instead-of-text/index.spec.js new file mode 100644 index 0000000000..5ee13d07d2 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/comment-instead-of-text/index.spec.js @@ -0,0 +1,26 @@ +export default { + props: { + showAsText: true, + }, + clientProps: { + showAsText: false, + }, + snapshot(target) { + const text = target.shadowRoot.firstChild; + return { + text, + }; + }, + test(target, snapshots, consoleCalls) { + const comment = target.shadowRoot.firstChild; + + expect(comment.nodeType).toBe(Node.COMMENT_NODE); + expect(comment.nodeValue).toBe(snapshots.text.nodeValue); + + expect(consoleCalls.error).toHaveSize(2); + expect(consoleCalls.error[0][0].message).toContain( + 'Hydration mismatch: incorrect node type received' + ); + expect(consoleCalls.error[1][0]).toContain('Recovering from error while hydrating'); + }, +}; diff --git a/packages/integration-karma/test-hydration/mismatches/comment-instead-of-text/x/main/main.html b/packages/integration-karma/test-hydration/mismatches/comment-instead-of-text/x/main/main.html new file mode 100644 index 0000000000..c6bfcf022e --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/comment-instead-of-text/x/main/main.html @@ -0,0 +1,4 @@ + diff --git a/packages/integration-karma/test-hydration/mismatches/comment-instead-of-text/x/main/main.js b/packages/integration-karma/test-hydration/mismatches/comment-instead-of-text/x/main/main.js new file mode 100644 index 0000000000..edfb80773a --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/comment-instead-of-text/x/main/main.js @@ -0,0 +1,5 @@ +import { LightningElement, api } from 'lwc'; + +export default class Main extends LightningElement { + @api showAsText; +} diff --git a/packages/integration-karma/test-hydration/mismatches/different-lwc-inner-html/index.spec.js b/packages/integration-karma/test-hydration/mismatches/different-lwc-inner-html/index.spec.js new file mode 100644 index 0000000000..07140c3d4f --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/different-lwc-inner-html/index.spec.js @@ -0,0 +1,35 @@ +export default { + props: { + content: '

test-content

', + }, + clientProps: { + content: '

different-content

', + }, + snapshot(target) { + const div = target.shadowRoot.querySelector('div'); + const p = div.querySelector('p'); + return { + div, + p, + }; + }, + test(target, snapshot, consoleCalls) { + const div = target.shadowRoot.querySelector('div'); + const p = div.querySelector('p'); + + expect(div).toBe(snapshot.div); + expect(p).not.toBe(snapshot.p); + expect(p.textContent).toBe('different-content'); + + expect(consoleCalls.warn).toHaveSize(1); + expect(consoleCalls.warn[0][0].message).toContain( + 'Mismatch hydrating element
: innerHTML values do not match for element, will recover from the difference' + ); + + target.content = '

another-content

'; + + return Promise.resolve().then(() => { + expect(div.textContent).toBe('another-content'); + }); + }, +}; diff --git a/packages/integration-karma/test-hydration/mismatches/different-lwc-inner-html/x/main/main.html b/packages/integration-karma/test-hydration/mismatches/different-lwc-inner-html/x/main/main.html new file mode 100644 index 0000000000..66a8e2c720 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/different-lwc-inner-html/x/main/main.html @@ -0,0 +1,3 @@ + diff --git a/packages/integration-karma/test-hydration/mismatches/different-lwc-inner-html/x/main/main.js b/packages/integration-karma/test-hydration/mismatches/different-lwc-inner-html/x/main/main.js new file mode 100644 index 0000000000..8066dd4ab7 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/different-lwc-inner-html/x/main/main.js @@ -0,0 +1,5 @@ +import { LightningElement, api } from 'lwc'; + +export default class Main extends LightningElement { + @api content; +} diff --git a/packages/integration-karma/test-hydration/mismatches/display-errors-attrs-class-style/index.spec.js b/packages/integration-karma/test-hydration/mismatches/display-errors-attrs-class-style/index.spec.js new file mode 100644 index 0000000000..1fdd078384 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/display-errors-attrs-class-style/index.spec.js @@ -0,0 +1,38 @@ +export default { + props: { + classes: 'ssr-class', + styles: 'background-color: red;', + attrs: 'ssr-attrs', + }, + clientProps: { + classes: 'client-class', + styles: 'background-color: blue;', + attrs: 'client-attrs', + }, + snapshot(target) { + return { + p: target.shadowRoot.querySelector('p'), + }; + }, + test(target, snapshots, consoleCalls) { + const p = target.shadowRoot.querySelector('p'); + + expect(p).not.toBe(snapshots.p); + + expect(p.className).toBe('client-class'); + expect(p.getAttribute('style')).toBe('background-color: blue;'); + expect(p.getAttribute('data-attrs')).toBe('client-attrs'); + + expect(consoleCalls.error).toHaveSize(4); + expect(consoleCalls.error[0][0].message).toContain( + 'Mismatch hydrating element

: attribute "data-attrs" has different values, expected "client-attrs" but found "ssr-attrs"' + ); + expect(consoleCalls.error[1][0].message).toContain( + 'Mismatch hydrating element

: attribute "class" has different values, expected "client-class" but found "ssr-class"' + ); + expect(consoleCalls.error[2][0].message).toContain( + 'Mismatch hydrating element

: attribute "style" has different values, expected "background-color: blue;" but found "background-color: red;"' + ); + expect(consoleCalls.error[3][0]).toContain('Recovering from error while hydrating'); + }, +}; diff --git a/packages/integration-karma/test-hydration/mismatches/display-errors-attrs-class-style/x/main/main.html b/packages/integration-karma/test-hydration/mismatches/display-errors-attrs-class-style/x/main/main.html new file mode 100644 index 0000000000..046a32c620 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/display-errors-attrs-class-style/x/main/main.html @@ -0,0 +1,3 @@ + diff --git a/packages/integration-karma/test-hydration/mismatches/display-errors-attrs-class-style/x/main/main.js b/packages/integration-karma/test-hydration/mismatches/display-errors-attrs-class-style/x/main/main.js new file mode 100644 index 0000000000..f73a8b980c --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/display-errors-attrs-class-style/x/main/main.js @@ -0,0 +1,7 @@ +import { LightningElement, api } from 'lwc'; + +export default class Main extends LightningElement { + @api classes; + @api styles; + @api attrs; +} diff --git a/packages/integration-karma/test-hydration/mismatches/favors-client-side-comment/index.spec.js b/packages/integration-karma/test-hydration/mismatches/favors-client-side-comment/index.spec.js new file mode 100644 index 0000000000..c960844ec9 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/favors-client-side-comment/index.spec.js @@ -0,0 +1,26 @@ +export default { + props: { + showFirstComment: true, + }, + clientProps: { + showFirstComment: false, + }, + snapshot(target) { + const comment = target.shadowRoot.firstChild; + return { + comment, + commentText: comment.nodeValue, + }; + }, + test(target, snapshots, consoleCalls) { + const comment = target.shadowRoot.firstChild; + expect(comment).toBe(snapshots.comment); + expect(comment.nodeValue).not.toBe(snapshots.commentText); + expect(comment.nodeValue).toBe('second'); + + expect(consoleCalls.warn).toHaveSize(1); + expect(consoleCalls.warn[0][0].message).toContain( + 'Hydration mismatch: comment values do not match, will recover from the difference' + ); + }, +}; diff --git a/packages/integration-karma/test-hydration/mismatches/favors-client-side-comment/x/main/main.html b/packages/integration-karma/test-hydration/mismatches/favors-client-side-comment/x/main/main.html new file mode 100644 index 0000000000..2fa6b76597 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/favors-client-side-comment/x/main/main.html @@ -0,0 +1,4 @@ + diff --git a/packages/integration-karma/test-hydration/mismatches/favors-client-side-comment/x/main/main.js b/packages/integration-karma/test-hydration/mismatches/favors-client-side-comment/x/main/main.js new file mode 100644 index 0000000000..cd370b7c74 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/favors-client-side-comment/x/main/main.js @@ -0,0 +1,5 @@ +import { LightningElement, api } from 'lwc'; + +export default class Main extends LightningElement { + @api showFirstComment; +} diff --git a/packages/integration-karma/test-hydration/mismatches/favors-client-side-text/index.spec.js b/packages/integration-karma/test-hydration/mismatches/favors-client-side-text/index.spec.js new file mode 100644 index 0000000000..a07e1fed79 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/favors-client-side-text/index.spec.js @@ -0,0 +1,26 @@ +export default { + props: { + greeting: 'hello!', + }, + clientProps: { + greeting: 'bye!', + }, + snapshot(target) { + const p = target.shadowRoot.querySelector('p'); + return { + p, + text: p.firstChild, + }; + }, + test(target, snapshots, consoleCalls) { + const p = target.shadowRoot.querySelector('p'); + expect(p).toBe(snapshots.p); + expect(p.firstChild).toBe(snapshots.text); + expect(p.textContent).toBe('bye!'); + + expect(consoleCalls.warn).toHaveSize(1); + expect(consoleCalls.warn[0][0].message).toContain( + 'Hydration mismatch: text values do not match, will recover from the difference' + ); + }, +}; diff --git a/packages/integration-karma/test-hydration/mismatches/favors-client-side-text/x/main/main.html b/packages/integration-karma/test-hydration/mismatches/favors-client-side-text/x/main/main.html new file mode 100644 index 0000000000..fd4547d4f7 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/favors-client-side-text/x/main/main.html @@ -0,0 +1,3 @@ + diff --git a/packages/integration-karma/test-hydration/mismatches/favors-client-side-text/x/main/main.js b/packages/integration-karma/test-hydration/mismatches/favors-client-side-text/x/main/main.js new file mode 100644 index 0000000000..aa674ccf41 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/favors-client-side-text/x/main/main.js @@ -0,0 +1,5 @@ +import { LightningElement, api } from 'lwc'; + +export default class Main extends LightningElement { + @api greeting; +} diff --git a/packages/integration-karma/test-hydration/mismatches/preserve-ssr-attr/index.spec.js b/packages/integration-karma/test-hydration/mismatches/preserve-ssr-attr/index.spec.js new file mode 100644 index 0000000000..ae3cfa8bb2 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/preserve-ssr-attr/index.spec.js @@ -0,0 +1,22 @@ +export default { + props: { + ssr: true, + }, + clientProps: { + ssr: false, + }, + snapshot(target) { + const p = target.shadowRoot.querySelector('p'); + return { + p, + title: p.title, + ssrOnlyAttr: p.getAttribute('data-ssr-only'), + }; + }, + test(target, snapshots) { + const p = target.shadowRoot.querySelector('p'); + expect(p).toBe(snapshots.p); + expect(p.title).toBe(snapshots.title); + expect(p.getAttribute('data-ssr-only')).toBe(snapshots.ssrOnlyAttr); + }, +}; diff --git a/packages/integration-karma/test-hydration/mismatches/preserve-ssr-attr/x/main/main.html b/packages/integration-karma/test-hydration/mismatches/preserve-ssr-attr/x/main/main.html new file mode 100644 index 0000000000..461836b6b8 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/preserve-ssr-attr/x/main/main.html @@ -0,0 +1,15 @@ + diff --git a/packages/integration-karma/test-hydration/mismatches/preserve-ssr-attr/x/main/main.js b/packages/integration-karma/test-hydration/mismatches/preserve-ssr-attr/x/main/main.js new file mode 100644 index 0000000000..8d248b4077 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/preserve-ssr-attr/x/main/main.js @@ -0,0 +1,5 @@ +import { LightningElement, api } from 'lwc'; + +export default class Main extends LightningElement { + @api ssr; +} diff --git a/packages/integration-karma/test-hydration/mismatches/style-attr/static-different-priority/index.spec.js b/packages/integration-karma/test-hydration/mismatches/style-attr/static-different-priority/index.spec.js new file mode 100644 index 0000000000..bfc6a45035 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/style-attr/static-different-priority/index.spec.js @@ -0,0 +1,30 @@ +export default { + props: { + ssr: true, + }, + clientProps: { + ssr: false, + }, + snapshot(target) { + const p = target.shadowRoot.querySelector('p'); + return { + p, + style: p.getAttribute('style'), + }; + }, + test(target, snapshots, consoleCalls) { + const p = target.shadowRoot.querySelector('p'); + + expect(p).not.toBe(snapshots.p); + expect(p.getAttribute('style')).not.toBe(snapshots.style); + expect(p.getAttribute('style')).toBe( + 'background-color: red; border-color: red !important;' + ); + + expect(consoleCalls.error).toHaveSize(2); + expect(consoleCalls.error[0][0].message).toContain( + 'Mismatch hydrating element

: attribute "style" has different values, expected "background-color: red;border-color: red important!" but found "background-color: red; border-color: red"' + ); + expect(consoleCalls.error[1][0]).toContain('Recovering from error while hydrating'); + }, +}; diff --git a/packages/integration-karma/test-hydration/mismatches/style-attr/static-different-priority/x/main/main.html b/packages/integration-karma/test-hydration/mismatches/style-attr/static-different-priority/x/main/main.html new file mode 100644 index 0000000000..a8bc6469a5 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/style-attr/static-different-priority/x/main/main.html @@ -0,0 +1,8 @@ + diff --git a/packages/integration-karma/test-hydration/mismatches/style-attr/static-different-priority/x/main/main.js b/packages/integration-karma/test-hydration/mismatches/style-attr/static-different-priority/x/main/main.js new file mode 100644 index 0000000000..8d248b4077 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/style-attr/static-different-priority/x/main/main.js @@ -0,0 +1,5 @@ +import { LightningElement, api } from 'lwc'; + +export default class Main extends LightningElement { + @api ssr; +} diff --git a/packages/integration-karma/test-hydration/mismatches/style-attr/static-extra-from-client/index.spec.js b/packages/integration-karma/test-hydration/mismatches/style-attr/static-extra-from-client/index.spec.js new file mode 100644 index 0000000000..3e6124f5db --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/style-attr/static-extra-from-client/index.spec.js @@ -0,0 +1,30 @@ +export default { + props: { + ssr: true, + }, + clientProps: { + ssr: false, + }, + snapshot(target) { + const p = target.shadowRoot.querySelector('p'); + return { + p, + style: p.getAttribute('style'), + }; + }, + test(target, snapshots, consoleCalls) { + const p = target.shadowRoot.querySelector('p'); + + expect(p).not.toBe(snapshots.p); + expect(p.getAttribute('style')).not.toBe(snapshots.style); + expect(p.getAttribute('style')).toBe( + 'background-color: red; border-color: red; margin: 1px;' + ); + + expect(consoleCalls.error).toHaveSize(2); + expect(consoleCalls.error[0][0].message).toContain( + 'Mismatch hydrating element

: attribute "style" has different values, expected "background-color: red;border-color: red;margin: 1px" but found "background-color: red; border-color: red"' + ); + expect(consoleCalls.error[1][0]).toContain('Recovering from error while hydrating'); + }, +}; diff --git a/packages/integration-karma/test-hydration/mismatches/style-attr/static-extra-from-client/x/main/main.html b/packages/integration-karma/test-hydration/mismatches/style-attr/static-extra-from-client/x/main/main.html new file mode 100644 index 0000000000..81671733e8 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/style-attr/static-extra-from-client/x/main/main.html @@ -0,0 +1,8 @@ + diff --git a/packages/integration-karma/test-hydration/mismatches/style-attr/static-extra-from-client/x/main/main.js b/packages/integration-karma/test-hydration/mismatches/style-attr/static-extra-from-client/x/main/main.js new file mode 100644 index 0000000000..8d248b4077 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/style-attr/static-extra-from-client/x/main/main.js @@ -0,0 +1,5 @@ +import { LightningElement, api } from 'lwc'; + +export default class Main extends LightningElement { + @api ssr; +} diff --git a/packages/integration-karma/test-hydration/mismatches/style-attr/static-extra-from-server/index.spec.js b/packages/integration-karma/test-hydration/mismatches/style-attr/static-extra-from-server/index.spec.js new file mode 100644 index 0000000000..bfcd9f62a2 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/style-attr/static-extra-from-server/index.spec.js @@ -0,0 +1,28 @@ +export default { + props: { + ssr: true, + }, + clientProps: { + ssr: false, + }, + snapshot(target) { + const p = target.shadowRoot.querySelector('p'); + return { + p, + style: p.getAttribute('style'), + }; + }, + test(target, snapshots, consoleCalls) { + const p = target.shadowRoot.querySelector('p'); + + expect(p).not.toBe(snapshots.p); + expect(p.getAttribute('style')).not.toBe(snapshots.style); + expect(p.getAttribute('style')).toBe('background-color: red; border-color: red;'); + + expect(consoleCalls.error).toHaveSize(2); + expect(consoleCalls.error[0][0].message).toContain( + 'Mismatch hydrating element

: attribute "style" has different values, expected "background-color: red;border-color: red" but found "background-color: red; border-color: red; margin: 1px"' + ); + expect(consoleCalls.error[1][0]).toContain('Recovering from error while hydrating'); + }, +}; diff --git a/packages/integration-karma/test-hydration/mismatches/style-attr/static-extra-from-server/x/main/main.html b/packages/integration-karma/test-hydration/mismatches/style-attr/static-extra-from-server/x/main/main.html new file mode 100644 index 0000000000..17340f9969 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/style-attr/static-extra-from-server/x/main/main.html @@ -0,0 +1,8 @@ + diff --git a/packages/integration-karma/test-hydration/mismatches/style-attr/static-extra-from-server/x/main/main.js b/packages/integration-karma/test-hydration/mismatches/style-attr/static-extra-from-server/x/main/main.js new file mode 100644 index 0000000000..8d248b4077 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/style-attr/static-extra-from-server/x/main/main.js @@ -0,0 +1,5 @@ +import { LightningElement, api } from 'lwc'; + +export default class Main extends LightningElement { + @api ssr; +} diff --git a/packages/integration-karma/test-hydration/mismatches/style-attr/static-same-different-order/index.spec.js b/packages/integration-karma/test-hydration/mismatches/style-attr/static-same-different-order/index.spec.js new file mode 100644 index 0000000000..b6d762a4e2 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/style-attr/static-same-different-order/index.spec.js @@ -0,0 +1,23 @@ +export default { + props: { + ssr: true, + }, + clientProps: { + ssr: false, + }, + snapshot(target) { + const p = target.shadowRoot.querySelector('p'); + return { + p, + style: p.getAttribute('style'), + }; + }, + test(target, snapshots, consoleCalls) { + const p = target.shadowRoot.querySelector('p'); + + expect(p).toBe(snapshots.p); + expect(p.getAttribute('style')).toBe(snapshots.style); + + expect(consoleCalls.error).toHaveSize(0); + }, +}; diff --git a/packages/integration-karma/test-hydration/mismatches/style-attr/static-same-different-order/x/main/main.html b/packages/integration-karma/test-hydration/mismatches/style-attr/static-same-different-order/x/main/main.html new file mode 100644 index 0000000000..73e2ad235f --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/style-attr/static-same-different-order/x/main/main.html @@ -0,0 +1,8 @@ + diff --git a/packages/integration-karma/test-hydration/mismatches/style-attr/static-same-different-order/x/main/main.js b/packages/integration-karma/test-hydration/mismatches/style-attr/static-same-different-order/x/main/main.js new file mode 100644 index 0000000000..8d248b4077 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/style-attr/static-same-different-order/x/main/main.js @@ -0,0 +1,5 @@ +import { LightningElement, api } from 'lwc'; + +export default class Main extends LightningElement { + @api ssr; +} diff --git a/packages/integration-karma/test-hydration/mismatches/style-attr/static-same-priority/index.spec.js b/packages/integration-karma/test-hydration/mismatches/style-attr/static-same-priority/index.spec.js new file mode 100644 index 0000000000..06a355fde7 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/style-attr/static-same-priority/index.spec.js @@ -0,0 +1,15 @@ +export default { + snapshot(target) { + const p = target.shadowRoot.querySelector('p'); + return { + p, + style: p.getAttribute('style'), + }; + }, + test(target, snapshots) { + const p = target.shadowRoot.querySelector('p'); + + expect(p).toBe(snapshots.p); + expect(p.getAttribute('style')).toBe(snapshots.style); + }, +}; diff --git a/packages/integration-karma/test-hydration/mismatches/style-attr/static-same-priority/x/main/main.html b/packages/integration-karma/test-hydration/mismatches/style-attr/static-same-priority/x/main/main.html new file mode 100644 index 0000000000..d427999eb8 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/style-attr/static-same-priority/x/main/main.html @@ -0,0 +1,4 @@ + diff --git a/packages/integration-karma/test-hydration/mismatches/style-attr/static-same-priority/x/main/main.js b/packages/integration-karma/test-hydration/mismatches/style-attr/static-same-priority/x/main/main.js new file mode 100644 index 0000000000..80164ac007 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/style-attr/static-same-priority/x/main/main.js @@ -0,0 +1,3 @@ +import { LightningElement } from 'lwc'; + +export default class Main extends LightningElement {} diff --git a/packages/integration-karma/test-hydration/mismatches/style-attr/static-same/index.spec.js b/packages/integration-karma/test-hydration/mismatches/style-attr/static-same/index.spec.js new file mode 100644 index 0000000000..fd3dbd4c66 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/style-attr/static-same/index.spec.js @@ -0,0 +1,17 @@ +export default { + snapshot(target) { + const p = target.shadowRoot.querySelector('p'); + return { + p, + style: p.getAttribute('style'), + }; + }, + test(target, snapshots, consoleCalls) { + const p = target.shadowRoot.querySelector('p'); + + expect(p).toBe(snapshots.p); + expect(p.getAttribute('style')).toBe(snapshots.style); + + expect(consoleCalls.error).toHaveSize(0); + }, +}; diff --git a/packages/integration-karma/test-hydration/mismatches/style-attr/static-same/x/main/main.html b/packages/integration-karma/test-hydration/mismatches/style-attr/static-same/x/main/main.html new file mode 100644 index 0000000000..120071ff36 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/style-attr/static-same/x/main/main.html @@ -0,0 +1,3 @@ + diff --git a/packages/integration-karma/test-hydration/mismatches/style-attr/static-same/x/main/main.js b/packages/integration-karma/test-hydration/mismatches/style-attr/static-same/x/main/main.js new file mode 100644 index 0000000000..80164ac007 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/style-attr/static-same/x/main/main.js @@ -0,0 +1,3 @@ +import { LightningElement } from 'lwc'; + +export default class Main extends LightningElement {} diff --git a/packages/integration-karma/test-hydration/mismatches/text-instead-of-comment/index.spec.js b/packages/integration-karma/test-hydration/mismatches/text-instead-of-comment/index.spec.js new file mode 100644 index 0000000000..df4c4c89a9 --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/text-instead-of-comment/index.spec.js @@ -0,0 +1,26 @@ +export default { + props: { + showAsText: false, + }, + clientProps: { + showAsText: true, + }, + snapshot(target) { + const comment = target.shadowRoot.firstChild; + return { + comment, + }; + }, + test(target, snapshots, consoleCalls) { + const text = target.shadowRoot.firstChild; + + expect(text.nodeType).toBe(Node.TEXT_NODE); + expect(text.nodeValue).toBe(snapshots.comment.nodeValue); + + expect(consoleCalls.error).toHaveSize(2); + expect(consoleCalls.error[0][0].message).toContain( + 'Hydration mismatch: incorrect node type received' + ); + expect(consoleCalls.error[1][0]).toContain('Recovering from error while hydrating'); + }, +}; diff --git a/packages/integration-karma/test-hydration/mismatches/text-instead-of-comment/x/main/main.html b/packages/integration-karma/test-hydration/mismatches/text-instead-of-comment/x/main/main.html new file mode 100644 index 0000000000..c6bfcf022e --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/text-instead-of-comment/x/main/main.html @@ -0,0 +1,4 @@ + diff --git a/packages/integration-karma/test-hydration/mismatches/text-instead-of-comment/x/main/main.js b/packages/integration-karma/test-hydration/mismatches/text-instead-of-comment/x/main/main.js new file mode 100644 index 0000000000..edfb80773a --- /dev/null +++ b/packages/integration-karma/test-hydration/mismatches/text-instead-of-comment/x/main/main.js @@ -0,0 +1,5 @@ +import { LightningElement, api } from 'lwc'; + +export default class Main extends LightningElement { + @api showAsText; +} diff --git a/packages/integration-karma/test-hydration/simple/index.spec.js b/packages/integration-karma/test-hydration/simple/index.spec.js new file mode 100644 index 0000000000..9a3ac5a005 --- /dev/null +++ b/packages/integration-karma/test-hydration/simple/index.spec.js @@ -0,0 +1,24 @@ +export default { + props: { + greeting: 'hello!', + }, + snapshot(target) { + const p = target.shadowRoot.querySelector('p'); + return { + p, + text: p.firstChild, + }; + }, + test(target, snapshots) { + const p = target.shadowRoot.querySelector('p'); + expect(p).toBe(snapshots.p); + expect(p.firstChild).toBe(snapshots.text); + expect(p.textContent).toBe('hello!'); + + target.greeting = 'bye!'; + + return Promise.resolve().then(() => { + expect(p.textContent).toBe('bye!'); + }); + }, +}; diff --git a/packages/integration-karma/test-hydration/simple/x/main/main.html b/packages/integration-karma/test-hydration/simple/x/main/main.html new file mode 100644 index 0000000000..fd4547d4f7 --- /dev/null +++ b/packages/integration-karma/test-hydration/simple/x/main/main.html @@ -0,0 +1,3 @@ + diff --git a/packages/integration-karma/test-hydration/simple/x/main/main.js b/packages/integration-karma/test-hydration/simple/x/main/main.js new file mode 100644 index 0000000000..aa674ccf41 --- /dev/null +++ b/packages/integration-karma/test-hydration/simple/x/main/main.js @@ -0,0 +1,5 @@ +import { LightningElement, api } from 'lwc'; + +export default class Main extends LightningElement { + @api greeting; +} diff --git a/scripts/tasks/check-license-headers.js b/scripts/tasks/check-license-headers.js index 544f9fbd7f..45960650a8 100644 --- a/scripts/tasks/check-license-headers.js +++ b/scripts/tasks/check-license-headers.js @@ -108,6 +108,7 @@ const CUSTOM_IGNORED_PATTERNS = [ '/fixtures/', '/integration-tests/src/(.(?!.*.spec.js$))*$', '/integration-karma/test/.*$', + '/integration-karma/test-hydration/.*$', ].map(createRegExp); const IGNORED_PATTERNS = [