From f2ca6892ac055d3e7ccf3bfb89934d850bf8bc3f Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Thu, 12 Sep 2024 08:29:34 +0200 Subject: [PATCH] Warn our user for hydration mismatches --- debug/src/debug.js | 8 ++++++ debug/test/browser/debug.test.js | 48 +++++++++++++++++++++++++++++++- mangle.json | 1 + src/diff/index.js | 10 +++++-- src/internal.d.ts | 1 + 5 files changed, 64 insertions(+), 4 deletions(-) diff --git a/debug/src/debug.js b/debug/src/debug.js index a5b660a9ac..6aeeaef17b 100644 --- a/debug/src/debug.js +++ b/debug/src/debug.js @@ -582,3 +582,11 @@ export function serializeVNode(vnode) { children && children.length ? '>..' : ' />' }`; } + +options._hydrationMismatch = (newVNode, excessDomChildren) => { + const { type } = newVNode; + const availableTypes = excessDomChildren.map(child => child.localName); + console.error( + `Expected a DOM node of type ${type} but found ${availableTypes.join(', ')}as available DOM-node(s), this is caused by the SSR'd HTML containing different DOM-nodes compared to the hydrated one.\n\n${getOwnerStack(newVNode)}` + ); +}; diff --git a/debug/test/browser/debug.test.js b/debug/test/browser/debug.test.js index 2dcb5a05ae..eda8e77534 100644 --- a/debug/test/browser/debug.test.js +++ b/debug/test/browser/debug.test.js @@ -1,4 +1,11 @@ -import { createElement, render, createRef, Component, Fragment } from 'preact'; +import { + createElement, + render, + createRef, + Component, + Fragment, + hydrate +} from 'preact'; import { useState } from 'preact/hooks'; import { setupScratch, @@ -870,4 +877,43 @@ describe('debug', () => { expect(console.error).to.not.be.called; }); }); + + describe('Hydration mismatches', () => { + it('Should warn us for a node mismatch', () => { + scratch.innerHTML = '
foo/div>'; + const App = () => ( +
+

foo

+
+ ); + hydrate(, scratch); + expect(console.error).to.be.calledOnce; + expect(console.error).to.be.calledOnceWith( + sinon.match(/Expected a DOM node of type p but found span/) + ); + }); + + it('Should not warn for a text-node mismatch', () => { + scratch.innerHTML = '
foo bar baz/div>'; + const App = () => ( +
+ foo {'bar'} {'baz'} +
+ ); + hydrate(, scratch); + expect(console.error).to.not.be.called; + }); + + it('Should not warn for a well-formed tree', () => { + scratch.innerHTML = '
foobar
'; + const App = () => ( +
+ foo + bar +
+ ); + hydrate(, scratch); + expect(console.error).to.not.be.called; + }); + }); }); diff --git a/mangle.json b/mangle.json index fcd2dce74f..42b128bf62 100644 --- a/mangle.json +++ b/mangle.json @@ -28,6 +28,7 @@ "$_listeners": "l", "$_cleanup": "__c", "$__hooks": "__H", + "$_hydrationMismatch": "__m", "$_list": "__", "$_pendingEffects": "__h", "$_value": "__", diff --git a/src/diff/index.js b/src/diff/index.js index 84c801bd02..40f17aea14 100644 --- a/src/diff/index.js +++ b/src/diff/index.js @@ -417,11 +417,15 @@ function diffElementNodes( newProps.is && newProps ); - // we created a new parent, so none of the previously attached children can be reused: - excessDomChildren = null; // we are creating a new node, so we can assume this is a new subtree (in // case we are hydrating), this deopts the hydrate - isHydrating = false; + if (isHydrating) { + if (options._hydrationMismatch) + options._hydrationMismatch(newVNode, excessDomChildren); + isHydrating = false; + } + // we created a new parent, so none of the previously attached children can be reused: + excessDomChildren = null; } if (nodeType === null) { diff --git a/src/internal.d.ts b/src/internal.d.ts index cbf23b3888..d015180e36 100644 --- a/src/internal.d.ts +++ b/src/internal.d.ts @@ -45,6 +45,7 @@ declare global { oldVNode?: VNode | undefined, errorInfo?: ErrorInfo | undefined ): void; + _hydrationMismatch?(vnode: VNode, excessDomChildren: Element[]): void; } export type ComponentChild =